medOS ultra

Auto-Assign Dispatcher

Runtime that fires the auto-assign engine on order events: Postgres trigger on the dispatch funnel, SUGGEST/AUTO modes, mover roles.

11 min read diagramsUpdated 2026-05-29docs/architecture/auto-assign-dispatcher.md

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.
  • metadata carries transport_request_id, transport_type, pickup/dropoff labels, so the spawned user_tasks row 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:

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):

  1. 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_recommendations where status='auto_assigned', or pg_net, calling POST foundation/acknowledgementRequests). Tracked in §6/P3.
  2. Porter self-claim visibility. Spawned gigs carry assignee_role = role_group (e.g. WHEELCHAIR_PORTER); PorterGigBoard filters its self-claim list by the logged-in user’s profile.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.
  3. Verification. Full-project tsc --noEmit is infeasible locally — it OOMs at 8 GB (--max-old-space-size=8192) after ~21 min because the @store import 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:

  1. Device as candidate. staff_assignments holds the Device’s id in user_id with a role_group like AGV_FLEET / ROBODOG and skills carrying capability flags (temperature_controlled, bsl3_rated). resolve_assignment_recommendations() ranks it like any candidate (a route_batch/nearest_available strategy fits robots well).
  2. External dispatch escalation. A Database Webhook on assignment_recommendations filtered to status='auto_assigned' AND role_group IN (device roles) calls the fleet/robot control API (via an edge function or pg_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_type resolves location → 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 on least_busy; Hospital B adds an AGV_FLEET rule with route_batch for its tube system; Hospital C leaves it off. Zero code divergence — same engine, different rows. Mirrors the policy_gates / cds_rules / facility_billing_rule house 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

  1. Off by default. Applying P0+P1 changes no behavior until a config row leaves mode='off'.
  2. department_queues is 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.
  3. Assignment passes through department_queues / user_tasks so workload counting stays correct. Domain assignee columns (claimed_porter_id, assigned_runner_id) are mirrors, never the dispatcher’s primary write.
  4. Reuse resolve + accept. The dispatcher orchestrates the existing RPCs; it does not reimplement ranking or assignment.
  5. AUTO never blocks on the network. Human assignment is in-transaction SQL. External (robot) dispatch is an async escalation off assignment_recommendations.
  6. Unmapped dept_types are ignored, not best-effort guessed. Adding a domain is an explicit operator action.
  7. Recommendations are an audit trail. Every suggestion (accepted/rejected/expired/auto) is a row, feeding getAcceptanceStats() for tuning.

9. Open questions

  1. P2 surface placement — inline queue-row affordance, dedicated dispatch board, or a QueueManagementFloater tab? Product call.
  2. 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.
  3. 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_pending index supports it.
  4. Specimen assigned_runner_id type — it’s uuid; recommendation recommended_user_id is text. The mirror-back for specimen needs a cast/validation. Porter (claimed_porter_id text) has no such issue. Handle when wiring specimen AUTO.
  5. Shift awareness for portersresolve cross-refs nursing_shifts for ward-scoped roles; hospital_wide porters bypass it. If porter rosters move to shifts, extend the shift check to hospital_wide.

10. References

Ask Anything