Auto-Assign Dispatcher
Runtime that fires the auto-assign engine on order events: Postgres trigger on the dispatch funnel, SUGGEST/AUTO modes, mover roles.
The runtime that fires the existing auto-assign rule engine on order events. The engine (
20260515_auto_assign_system.sql) can already rank eligible staff, log recommendations, and apply an assignment — but nothing invokes it outside the admin config page. This doc specifies the dispatcher that closes that gap: when work lands in a department queue, resolve → recommend (SUGGEST) or auto-assign (AUTO), per-hospital configurable, with a designed-in path for robot/Device movers.Status: Design + pilot, 2026-05-29. P0+P1 (SQL substrate) shipped as migrations
20260529a+20260529b. P2 (operational surface) and P3 (robot dispatch) open.Read first:
hospital-movement-architecture.md(mover model, custody handoff),20260515_auto_assign_system.sql(the engine this drives).
0. TL;DR — The Verdicts
| # | Verdict | Means |
|---|---|---|
| 1 | The engine exists; only the trigger was missing. | auto_assign_configs + auto_assign_role_rules + staff_assignments + resolve_assignment_recommendations() + accept_assignment_recommendation() are production code. We add a dispatcher, not a second engine. |
| 2 | Postgres trigger, not edge function. | The dispatcher fires AFTER INSERT ON department_queues, in-transaction, calling the existing SQL functions. Matches the house pattern (trg_sync_billing_queue, trg_sync_specimen_transport_queue). Works on-prem with zero edge-fn deploy. Robot/external dispatch is an additive escalation (§6), not the core path. |
| 3 | department_queues is the single dispatch funnel. |
Every order type already lands (or can land) a department_queues row. One trigger covers all 11 order types. Domains missing a queue projection get one (porter transport: P0). |
| 4 | Assignee lands on the work surface, mirrored to the domain. | In-place roles (lab tech, OPD nurse) → department_queues.assigned_to. Mover roles with spawn_task_type (porter, runner) → spawn a user_tasks row → user_tasks.assigned_to, the surface PorterGigBoard actually reads. |
| 5 | Off by default = zero behavior change. | All 11 order types ship mode='off'. The dispatcher is inert until a hospital flips a config row at /admin/auto-assign. No new behavior is forced on any deployment. |
| 6 | Robots are movers-in-waiting, not a rebuild. | A FHIR Device (robodog/AGV/tube) is just another assignment candidate. The pilot assigns humans; §6 specifies the additive Device path so robot dispatch slots in without touching the core trigger. |
1. The Problem (verified current state)
resolve_assignment_recommendations() and accept_assignment_recommendation() are
called from exactly one place: autoAssign.service.ts,
reachable only via the admin route /admin/auto-assign. No edge function, no
trigger, no cron invokes them. So:
- The engine is a brain with no nervous system: it can decide who should take a job, but nothing asks it when a job appears.
- Even
mode='auto'has no runtime — the migration comments name an “AUTO mode dispatcher” that was never built. - SUGGEST-mode recommendations never surface where work lands (porter board, lab worklist, queue rows).
Two domain-specific facts shape the fix:
| Fact | Source | Consequence |
|---|---|---|
transport_request (porter, migration 044) has no department_queues / user_tasks projection |
only stretcher-location / duration / event-log triggers exist | porter transport is invisible to the queue funnel + workload counting → P0 adds the projection |
PorterGigBoard reads user_tasks, not transport_request |
PorterGigBoard.tsx:343 |
to assign a porter, the job must land on user_tasks — which the seed already anticipates via spawn_task_type='patient_transport' |
specimen_transport_request already projects to department_queues (dept_type='specimen_transport') |
trg_sync_specimen_transport_queue |
specimen transport is dispatcher-ready with no new projection |
2. Architecture — the funnel
order placed (any domain)
│
▼
┌──────────────────────────┐
│ department_queues row │ ← the universal operational queue (read model)
│ INSERT │ • specimen_transport: already synced (20260527c)
└────────────┬─────────────┘ • patient_transport: synced by P0 (this work)
│ • lab/imaging/consult/…: existing producers
│ AFTER INSERT
▼
┌──────────────────────────┐
│ trg_auto_assign_dispatch │ ← P1: the dispatcher
│ 1. dept_type → order_type│
│ 2. read config mode │──── 'off' / no config ──▶ (no-op; default)
│ 3. resolve_assignment_ │
│ recommendations() │
│ 4. per recommended role: │
│ • spawn_task_type set?─┼─ yes ─▶ ensure user_tasks row, rec.task_id = it
│ (porter / runner) │
│ • else ┼─ no ─▶ rec.queue_row_id = department_queues.id
│ 5. INSERT assignment_ │
│ recommendations[] │
│ 6. mode = 'auto'? │── yes ─▶ accept_assignment_recommendation(rank1)
└────────────┬─────────────┘
│
┌─────┴───────────────────────────────┐
│ │
SUGGEST mode AUTO mode
recs sit 'pending' rank-1 accepted by 'auto-dispatcher'
│ │
▼ ▼
operational panel (P2) assigned_to written:
useAutoAssignRecommendations • user_tasks.assigned_to (porter/runner)
+ AssignmentRecommendations • department_queues.assigned_to (in-place)
realtime via subscribeRecommendations │
│ ▼
└──────────────▶ human accepts ──▶ PorterGigBoard / worklist shows assignee
Why department_queues and not the domain tables? Three reasons: (a) it is
already the canonical operational queue read model the whole app consumes; (b)
resolve_assignment_recommendations() counts workload from department_queues +
user_tasks, so assignments are only correctly load-balanced if they pass through
those tables; © one trigger covers every order type instead of N per-domain
triggers.
3. P0 — Transport queue projection
transport_request must appear in department_queues for the dispatcher to see
it and for porter workload to count. New migration
20260529a_transport_queue_sync.sql
adds trg_sync_transport_queue, a near-exact copy of the specimen trigger:
dept_type = 'patient_transport',ticket_id = 'transport:' || NEW.id(unique, prefixed to avoid collision — same discipline as billing/specimen/transfusion).- status map:
pending|claimed → WAITING,dispatched|at_pickup|en_route → IN_PROGRESS,completed → COMPLETED,cancelled|held → CANCELLED/HOLD. metadatacarriestransport_request_id,transport_type, pickup/dropoff labels, so the spawneduser_tasksrow and the dispatcher can read context without a join.
Pure addition. Behaviorally inert except that the porter request now has a queue presence (which is correct regardless of auto-assign — it unifies the porter island with the rest of the operational model).
4. P1 — The dispatcher trigger
New migration 20260529b_auto_assign_dispatcher.sql.
trg_auto_assign_dispatch fires AFTER INSERT ON department_queues.
4.1 dept_type → order_type map
The pilot maps only the two transport dept_types; everything else is a no-op (returns early). This bounds blast radius — the dispatcher cannot touch lab/imaging/ consultation queues until an operator adds the mapping and enables the config.
'patient_transport' → 'transport'
'specimen_transport' → 'specimen_transport'
ELSE → RETURN -- unmapped: dispatcher ignores
Rolling out to a new domain = add one CASE arm + flip its config mode. No other code.
4.2 Recursion safety
The trigger is AFTER INSERT only. AUTO-mode assignment is an UPDATE on
department_queues.assigned_to / user_tasks.assigned_to — neither re-fires an
INSERT trigger. Spawning a user_tasks row does not write back to
department_queues. No recursion guard flag needed.
4.3 Mover-role bridge (spawn_task_type)
For a recommended rule with spawn_task_type set (porter/runner), the dispatcher
ensures a user_tasks row exists for this queue (idempotent via
metadata.queue_row_id), then logs the recommendation with task_id pointing at
it. accept_assignment_recommendation() already writes user_tasks.assigned_to
when task_id is set — so AUTO mode reuses the existing accept logic untouched.
For in-place rules (no spawn_task_type: lab tech, OPD nurse, doctor), the
recommendation carries queue_row_id, and accept writes department_queues.assigned_to.
4.4 Gating
Step 2 reads auto_assign_configs for the order_type at the most-specific active
scope. mode='off' or no row → return immediately. This is the default for all
11 seeded order types, so applying these migrations changes nothing until a
hospital opts in.
5. P2 — Operational SUGGEST surface + notify-on-assign (shipped)
The frontend pieces already existed and were unused outside admin:
useAutoAssignRecommendations— mode + recs for a queue row.AssignmentRecommendations.tsx— the ranked-candidate panel with accept/reject.subscribeRecommendations(queueRowId)— realtime inautoAssign.service.ts:322.
Surface (shipped): the AssignmentRecommendations panel now mounts in
PorterGigBoard
beneath each pending gig that the dispatcher spawned (task.payload.queue_row_id
present). The panel is self-contained (returns null unless the order type’s config
is suggest/auto), so the board is unchanged on every default deployment.
Notify-on-assign (shipped): AssignmentRecommendations.handleAccept now fires
createAcknowledgementRequest
on a successful accept (best-effort, isolated try/catch — a notify failure never
undoes the assignment). orderType: 'custom', recipient = the assigned user,
channels: ['app','web'], priority mapped from context. This routes through the
existing messaging dispatcher (app/web live; email/SMS/push as those channels land).
Because the notify lives in the shared accept handler, it fires from both the
porter board and the admin page — accepting a recommendation is a real assignment
everywhere.
Known follow-ups (not in P2):
- AUTO-mode notify. The notify is on the frontend accept path, so it covers SUGGEST. In AUTO mode the Postgres trigger auto-accepts with no frontend in the loop → no notification. Closing it needs a Supabase→backend bridge (Database Webhook on
assignment_recommendationswherestatus='auto_assigned', orpg_net, callingPOST foundation/acknowledgementRequests). Tracked in §6/P3.- Porter self-claim visibility. Spawned gigs carry
assignee_role = role_group(e.g.WHEELCHAIR_PORTER);PorterGigBoardfilters its self-claim list by the logged-in user’sprofile.role[0].name. Those vocabularies must align for a porter to see the gig for self-claim (the recommend/accept panel works regardless). Reconcile role naming, or relax the board’s filter to a porter-role set.- Verification. Full-project
tsc --noEmitis infeasible locally — it OOMs at 8 GB (--max-old-space-size=8192) after ~21 min because the@storeimport graph pulls essentially the whole app. P2 was verified via esbuild syntax/JSX validation
- static type/import checks;
pnpm build(CI) remains the authoritative gate.
6. P3 — Robot / Device movers (future, designed-for)
Per hospital-movement-architecture.md §4,
a robot/AGV/pneumatic-tube/robodog is a FHIR Device with specialization[]
capability flags — the mover equivalent of a Practitioner. The dispatcher does
not need to change to support them. Two additive moves:
- Device as candidate.
staff_assignmentsholds the Device’s id inuser_idwith arole_grouplikeAGV_FLEET/ROBODOGandskillscarrying capability flags (temperature_controlled,bsl3_rated).resolve_assignment_recommendations()ranks it like any candidate (aroute_batch/nearest_availablestrategy fits robots well). - External dispatch escalation. A Database Webhook on
assignment_recommendationsfiltered tostatus='auto_assigned' AND role_group IN (device roles)calls the fleet/robot control API (via an edge function orpg_net). The human path stays pure SQL and instant; the robot path is a bolt-on that never complicates the core trigger.
This is why Verdict 2 chose a trigger for the core: the 95% case (assign a human who’s already on the board) needs no network call; the 5% case (command a machine) is isolated to an escalation that can fail/retry without blocking the assignment record.
Per-hospital, by construction. “Each hospital may have different ones” is already satisfied:
auto_assign_configs.scope_typeresolveslocation → sub_clinic → clinic → global(most-specific wins), and configs / role rules / staff assignments / device fleets are all data rows edited at/admin/auto-assign. Hospital A runs porters onleast_busy; Hospital B adds anAGV_FLEETrule withroute_batchfor its tube system; Hospital C leaves itoff. Zero code divergence — same engine, different rows. Mirrors thepolicy_gates/cds_rules/facility_billing_rulehouse pattern.
7. Phases
| Phase | Scope | Artifact | Status |
|---|---|---|---|
| P0 | Transport → department_queues projection |
20260529a_transport_queue_sync.sql |
✅ this work |
| P1 | Dispatcher trigger (SUGGEST + gated AUTO) | 20260529b_auto_assign_dispatcher.sql |
✅ this work |
| P2 | SUGGEST panel in PorterGigBoard + notify-on-assign (frontend accept → createAcknowledgementRequest) |
PorterGigBoard.tsx, AssignmentRecommendations.tsx |
✅ this work |
| P3 | AUTO-mode notify bridge (Supabase webhook → ack endpoint), robot/Device dispatch, roll-out to lab/imaging/consult, porter role-name reconcile | webhook + config rows | ◻ future |
Migrations are manual-apply (per root CLAUDE.md deployment table — Supabase
migrations are not auto-deployed). Nothing changes in any environment until
psql -f is run and a config is flipped off off.
8. Invariants
- Off by default. Applying P0+P1 changes no behavior until a config row leaves
mode='off'. department_queuesis the only dispatch trigger point. No per-domain dispatch triggers; domains join the funnel by projecting a queue row + a dept_type→order_type map arm.- Assignment passes through
department_queues/user_tasksso workload counting stays correct. Domain assignee columns (claimed_porter_id,assigned_runner_id) are mirrors, never the dispatcher’s primary write. - Reuse
resolve+accept. The dispatcher orchestrates the existing RPCs; it does not reimplement ranking or assignment. - AUTO never blocks on the network. Human assignment is in-transaction SQL. External (robot) dispatch is an async escalation off
assignment_recommendations. - Unmapped dept_types are ignored, not best-effort guessed. Adding a domain is an explicit operator action.
- Recommendations are an audit trail. Every suggestion (accepted/rejected/expired/auto) is a row, feeding
getAcceptanceStats()for tuning.
9. Open questions
- P2 surface placement — inline queue-row affordance, dedicated dispatch board, or a
QueueManagementFloatertab? Product call. - AUTO-mode actor identity — recommendations accepted by the dispatcher are stamped
decided_by='auto-dispatcher'. Should this be a real seeded service-account user id for audit joins? Likely yes before AUTO ships to a real customer. - Re-dispatch on no-claim — if a SUGGEST/broadcast job sits unclaimed past its time limit, should a cron re-resolve (workload changes over time)? Defer until volume is observable; the
idx_recommendations_pendingindex supports it. - Specimen
assigned_runner_idtype — it’suuid; recommendationrecommended_user_idistext. The mirror-back for specimen needs a cast/validation. Porter (claimed_porter_id text) has no such issue. Handle when wiring specimen AUTO. - Shift awareness for porters —
resolvecross-refsnursing_shiftsfor ward-scoped roles;hospital_wideporters bypass it. If porter rosters move to shifts, extend the shift check tohospital_wide.
10. References
hospital-movement-architecture.md— mover model (Practitioner + Device), custody handoff, policy-gate override20260515_auto_assign_system.sql— the engine (configs, role rules, staff, resolve/accept RPCs)specimen-transport-bounded-context.md— specimen carrier pool, the queue-sync trigger this mirrorsautoAssign.service.ts— frontend service (already complete: resolve/save/accept/subscribe)queue-management-floater.md— candidate host for the P2 surfacepolicy-gates.md— the data-driven-rule house pattern this follows