OPD AI Autopilot
AI intake screening kit + per-station autopilot + edge-server AI runtime fabric, registration to settlement, recommender-first.
Status: Master design (2026-06-10). Nothing in this doc is built yet except where a row says SHIPPED.
Scope: OPD clinic flow, registration → screening → doctor assignment → consult → diagnosis → orders → pharmacy → cashier → revenue settlement. Scales to hospital later (IPD explicitly out of scope until the IPD→ConfigDrivenTable migration lands).
Posture: One new additive kit (@medical-kit/opd-autopilot + module opd-autopilot), default-OFF on every plane, zero behavior change when off. Every station transition remains drivable manually exactly as today; the autopilot adds a parallel driver that suggests or executes the same REST calls the buttons fire — never a new write path.
Packaging standard: Homogenized with the AHP care-pathway kit (docs/architecture/ahp-led-care-pathway.md §5 is the canonical packaging reference — six-host seam matrix, two-plane entitlement, engine-under-adapters, module.json). The AI agent itself is modeled as a physician-supervised non-physician practitioner: the autonomy ladder maps onto the AHP lanes (suggest = Lane B cosign, auto = Lane A capped under a signed envelope, red flag = forced refer).
Origin: grounded by a 17-agent design pass (12 surface mappers + 2 backfills + 3 adversarial verifiers, 2026-06-10); the verifiers’ blockers are folded in below and marked [V].
0. TL;DR
Three planes, one kit:
| Plane | What it is | P-phase |
|---|---|---|
| P1 Screening Station | AI-assisted intake screening wrapped around the existing ScreeningV2: STT chief complaint + vitals through recordObservation + the AHP deterministic red-flag detector + an AI triage proposal (acuity, suggested dept/doctor, next station). New screening_sessions table. |
P1 (demo-ready) |
| P2 Autopilot Engine | Per-station opd_autopilot_configs (off|assist|suggest|auto) + a guarded Postgres dispatcher trigger + opd_autopilot_tasks queue + workers that claim tasks and either write a cowork proposal (suggest) or call the existing transition endpoint (auto). Doctor assignment delegates to the already-seeded auto-assign engine. |
P2–P3 |
| P3 AI Runtime Fabric | “Hypervirtualization”: one logical AI runtime over N physical nodes — clinic edge box, medOS-ultra server, optional cloud. ai_runtime_nodes registry + heartbeating node agent that doubles as the autopilot worker + resolve_llm_node() routing with PHI-residency enforcement. P0/P1 run server-tier-only (the Ollama that already ships in docker-compose-onpremise.yml / on PH EC2). |
P4 |
Clinical autonomy (auto-committing diagnosis/prescription) is hard-capped at suggest until a physician signs an AI practice envelope (P5) — a new table, NOT a reuse of ahp_capability_grant, because the verifier showed the AHP schema’s named-physician-author CHECK and gate log cannot express “AI recommended → human approved” [V].
1. The OPD line today (grounded, as shipped)
S0 REGISTRATION S1 SCREENING S2 DOCTOR ASSIGN S3-S4 CONSULT + DX
/checkin/kiosk (public) ScreeningV2 "ซักประวัติ" auto_assign_configs patient-profile
/checkin/mobile (PWA) clinic_screening_config 'consultation' SEEDED SmartDiagnosis (siloed)
FormRegister (+AI variant) recordObservation → DOCTOR rule seeded VoiceOrder → orderRequests
POST /openapi/kiosk- POST /v2/diagnostic/ (already_assigned, seq 1) POST /v2/diagnostic/diagnosiss
registration/createPatient observations dispatcher trigger does PUT /v2/administration/
POST /openapi/encounter/ dept_type='screening' NOT map consultation ❌ encounters/{id}/start
create workflow state: strategy unimplemented ❌
HN via global-sequence wait-screening
│ │ │ │
▼ ▼ ▼ ▼
department_queues ←─ queue-transition endpoint ─→ encounter_journey_cache ←─ hospital_events
(WAITING/CALLED/ACTIVE/COMPLETED) (realtime to all worklists) (orchestrator)
S5 ORDERS/RX S6 PHARMACY S7 CASHIER S8 SETTLEMENT
POST /v2/medication/ PUT …/updatescreened/:id trg_sync_billing_queue /revenue-settlement
orderRequests PUT …/qcVerification/:id (financial_summary → 12-tab workspace
(8 safety checks fire PUT …/:id/ dept_type='billing') closeOPDDay
server-side at create) updateMedicationRequest- GenerateBill (CASH/EDC/QR) POST /v2/financial/
POST …/orderRequests/sign Status (completed → POST /v2/financial/ billCycleSettlement/…
resolveOrderReleaseGate emitPharmacyDispensed) receipts/createPayments claimStatus: draft→…→paid
(auto|nurse_ack|…|cosign) dispense_medication gate NO policy gate wired ❌
(pharmacyVerifyGate.ts) ✅
The transition machinery (what autopilot must drive, verified end-to-end):
every worklist button resolves via MANIFEST_TRANSITION_REGISTRY (merged from the 23 workflow JSONs in web/packages/medical-kit/src/medical-worklist/defaults/) inside workflowActionHandler.ts and executes one of three modes:
domain-then-transition— domain REST call (e.g.PUT /v2/administration/encounters/{encounterId}/start) thenPOST /v2/medication/encounterJourney/queue-transitionwith{encounterId, targetWorkflowState, fromWorkflowState, note};queue-transition— the queue call only;dispatch-release—POST /v2/medication/encounterJourney/dispatch-release.
OPD workflow states: wait-check-in → wait-screening → wait-doctor → doctor-in-process → wait-acknowledgement → doctor-finish (MASTER_WORKFLOW_NODES). A new station is pure data: a node + manifestTransitions entry in the workflow JSON + a role_worklists row — WorkflowConfigProvider.getColumnsForNode and workflow-config-merger resolve it with zero code. The realtime refresh is push-based (Supabase subscriptions on encounter_journey_cache + department_queues).
Consequence for the whole design: the autopilot worker needs exactly two REST verbs per station hop — the domain call and the queue-transition call — and they are the same ones the human buttons fire. That is what makes invariant #1 (no new write path) cheap to honor.
What is already AI-shaped and shipped
| Asset | State |
|---|---|
ScreeningV2 + clinic_screening_config + screening_master_config + /super-admin/screening-config |
SHIPPED — the screening surface to wrap, not replace |
dialog-register-ai/FormRegister.tsx |
SHIPPED — AI-assisted registration variant |
Kiosk + mobile self check-in (/checkin/kiosk, /checkin/mobile, CheckinSession) |
SHIPPED |
auto_assign_configs row consultation + DOCTOR/NURSE_OPD rules |
SHIPPED (mode off; dispatcher mapping + already_assigned ranking missing) |
services/llm platform (models/use-cases/corpora/audit, pgvector RAG, redaction, rate limits) |
SHIPPED, single-node |
Cowork substrate (cowork_agents, cowork_proposals, agent_proposal ack, Inbox Accept/Edit/Reject, cowork-proposal-decide) |
SHIPPED — propose-only; write_action is an unconsumed placeholder [V] |
| AHP kit engine (red-flag detector dual-plane, gate evaluator, capped states) | SHIPPED (working tree), default-off |
| Order-create safety checks (allergy/DDI/pregnancy/age/lactation/stale/dosage/duplication) | SHIPPED server-side in orderRequest.calculateMedicationAllergyInteraction.ts |
dispense_medication policy gate + pharmacy_verify_scan_results |
SHIPPED (web/src/utils/pharmacyVerifyGate.ts) |
Ollama service in docker-compose-onpremise.yml (lines ~568–593) + api-llm + mistral:7b live on PH EC2 |
SHIPPED — the server tier of the fabric exists today |
2. The kit at a glance
infrastructure/modules/opd-autopilot/module.json ← requires: [read-model-core, ahp-care-pathway]
web/packages/medical-kit/src/opd-autopilot/ ← @medical-kit/opd-autopilot (engine-under-adapters)
types.ts entitlement.ts index.ts
engine/ triageEngine.ts (pure: acuity + dept/doctor suggestion contract)
stationDrivers.ts (per-station driver contracts S0–S8, pure)
autonomyLadder.ts (mode resolution: config × entitlement × jurisdiction × envelope)
__tests__/ (property tests: red-flag precedence, ladder caps, VN-style no-auto)
components/ ScreeningStationLayout.tsx (wraps ScreeningV2: VitalSign capture + AI panel slot)
ScreeningAIProposalPanel.tsx (triage proposal: acuity, red-flag banner, suggest chips)
AutopilotControlPanel.tsx (/admin/opd-autopilot page body — mirrors AutoAssignPage)
FlowPulseBoard.tsx (pipeline view: encounters × stations, AI/manual badges)
adapters/ autopilotModule.ts (footer ModuleDefinition → 'opd-autopilot/ScreeningIntake',
'opd-autopilot/ProposalReview')
registerOpdActions.ts (payload builders: 'autopilot-screening-intake',
'autopilot-proposal-review')
fabricClient.ts (resolve node → call /api/llm/* — thin, fail-soft)
services/llm/src/api/llm/modules/autopilotWorker/ ← server-tier worker (P3); same loop ships in the
edge node agent (P4)
services/clinical/src/api/clinical/modules/aiPractice/ ← P5 trust anchor: enforceAiPracticeGate
infrastructure/medbase/migrations/20260611a–f_*.sql ← see §5
infrastructure/scripts/setup-edge-ai.sh ← P4: edge box provisioning (mirrors setup-onpremise.sh)
infrastructure/scripts/medos-ai-node-agent/ ← P4: heartbeat + worker loop container
infrastructure/market-packs/medos-*/seed-opd-autopilot-*.sql ← per-country configs (all off / no-auto caps)
web/sandbox: ?target=OpdAutopilotScreening · ?target=AutopilotControl · ?target=FlowPulse
Inside medical-kit (the ahp-care-pathway/treatment-series-engine precedent) ⇒ no tsconfig/vite changes.
Entitlement — one logical flag, two planes (AHP pattern, byte-for-byte)
| Plane | Predicate | Default |
|---|---|---|
| Frontend (advisory — what mounts) | autopilotEnabled() = VITE_OPD_AUTOPILOT_ENABLED==='true' && 'opd-autopilot' ∈ VITE_ENABLED_MODULES && tier ⊇ premium |
OFF |
| Backend (authoritative — what commits) | env OPD_AUTOPILOT_ENABLED==='true' && tier && module_enabled_server('opd-autopilot') DB row |
OFF |
FEATURE_PLANS additions (fixes the unmapped-key-entitles-everything fallback [V]):
'opd.autopilot': ['premium','enterprise'] · 'opd.autopilot.auto': ['enterprise'] (auto-mode anywhere) · 'cowork.proposal': ['premium','enterprise'] (retro-gates the Inbox panel [V]).
Off-semantics: frontend off ⇒ the guarded boot block in web/src/store/index.ts no-ops ⇒ no modals, no payload builders, no AI panel inside ScreeningV2 — hosts byte-identical. Backend off ⇒ the worker refuses to claim, the dispatcher trigger no-ops (no config row), and clinical enforcement returns FEATURE_NOT_ENTITLED — it never silently downgrades to a more permissive mode.
Six-host integration matrix (inherits AHP §5 verbatim; autopilot deltas only)
| Host | Seam | Autopilot mechanism |
|---|---|---|
| Footer global modal | ✅ moduleRegistry + registerDynamicModals() |
'opd-autopilot/ScreeningIntake', 'opd-autopilot/ProposalReview'; isActive() re-checked per render |
| OPD worklist | ✅ registerPayloadBuilder() + workflow JSON / role_worklists data |
screening + proposal actions; inert until a workflow row references them |
| Workflow-store | ✅ workflow_templates IS the registry |
“OPD Autopilot line” template_set: the standard OPD JSONs + screening-station node + autopilot action annotations |
| Workflow-editor | fork avoided | autopilot mode lives in opd_autopilot_configs rows + policy_gates, never on the canvas; no new nodeTypes |
| Admin config | ❌ AdminRoutes.tsx hardcoded (same gap as AHP) |
/admin/opd-autopilot lands on the shared registerAdminPage() seam when built; near-term: one route line in AdminRoutes (accepted, minimal, reversible) |
| IPD worklist | out of scope | inherits the platform’s IPD→ConfigDrivenTable backlog item |
3. The autonomy ladder and the legal scaffold
Per station, per scope (location > sub_clinic > clinic > global — the auto-assign resolution order):
| Mode | Meaning | Who commits | AHP-lane equivalent |
|---|---|---|---|
off |
nothing fires; trigger no-ops | human only | — |
assist |
inline hints on the open surface (no queue writes, no proposals) | human | no of-record authorship |
suggest |
worker writes cowork_proposals + agent_proposal ack; human Accept executes the same endpoint the button fires |
human (the accept) | Lane B — cosign; the proposal IS the cosign request |
auto |
worker calls the existing endpoint directly under the agent identity, then notifies | AI, attributed + undoable | Lane A — capped, under a signed envelope |
| red flag | deterministic detector fires → forced refer/hold; AI output discarded [V] | — | refer (always ungated) |
Per-station autonomy ceilings (enforced in autonomyLadder.ts AND server-side; config rows cannot exceed them):
| Station | Ceiling without envelope | Ceiling with signed AI practice envelope (P5) | Rationale |
|---|---|---|---|
| S0 registration / check-in | auto (deterministic only: exact CID/HN match — the DocumentIntakeInbox rule) |
same | identity errors are recoverable but costly; AI alone never merges identities |
| S1 screening / triage | suggest for triage class; auto for the queue hop once a nurse saves the form |
auto (triage commit capped at triage_committed) |
matches AHP screening capped state |
| S2 doctor assignment | auto |
auto |
operational, reversible, already the auto-assign engine’s job |
| S3 consult assist | assist (by definition) |
assist |
the doctor is present |
| S4 diagnosis commit | suggest |
auto → capped state treated_per_standing_order, never diagnosed |
FORBIDDEN_AHP_STATES carries over verbatim |
| S5 orders / prescription | suggest (order draft) |
auto only for envelope drug_whitelist SKUs; sign via existing resolveOrderReleaseGate |
the 8 prescribe-time safety checks still run server-side |
| S6 pharmacy verify | suggest (pre-verified annotation) |
suggest — pharmacist always dispenses |
physical handoff; dispense_medication gate stays |
| S7 billing / cashier | auto for charge materialization + queue advance; payment confirmation NEVER auto unless a payment gateway confirms |
same | invariant #11 |
| S8 settlement / claims | suggest (draft claim batch, anomaly flags) |
suggest |
money leaves the building; human signs |
The AI practice envelope (NOT the AHP tables) [V]
The safety verifier rejected reusing ahp_capability_grant.role_key='ai-agent': the active ⇒ author_physician_id NOT NULL CHECK encodes a physician owns the practitioner’s grant, but an AI cannot be the practitioner of record, and ahp_gate_log cannot distinguish “AI recommended → human approved” from “human decided”. So the kit ships its own pair, same shape, AI-specific provenance:
ai_practice_envelope— likeahp_scope_envelopeplus:ai_model_name,ai_model_version,ai_use_case_code(→llm_use_cases.code),prompt_hash,supervising_physician_id NOT NULL(the legal owner),samd_regulated/approval_number/risk_class/intended_use[V],drug_whitelist,red_flag_pack, station/action allowlist,status draft→signed→retiredwith the same signed-requires-signer CHECK.ai_capability_grant— likeahp_capability_grantbutagent_slug(→cowork_agents.slug) instead ofrole_key,verdict ∈ (suggest|auto|forbid),envelope_ref NOT NULLforauto, per-jurisdiction; market-pack seeds can forbidautoper country (the VN-invariant pattern —medos-vietnamseeds shipverdict='suggest'rows only, and the engine downgrades any mis-seededautoexactly as the AHP gate evaluator does).ai_gate_log— append-only likeahp_gate_logplusai_recommendation_id,ai_model_name,ai_latency_ms,approval_kind ∈ (human_accept|human_edit|auto_envelope)— the “AI recommended vs human decided” distinction the AHP log lacks.
The gate evaluator is shared: evaluateAhpGate’s decision order (red flags → refer; refer ungated; default-deny; jurisdiction invariant; Lane A iff envelope valid else Lane B) is imported from @medical-kit/ahp-care-pathway and fed AI grants — one tested brain, two actor kinds. policy_gates gains two rows scoped care_settings:['ai-autopilot'] mirroring commit_diagnosis/commit_prescription from 20260610d (hard_stop, no override; the escape path is refer-to-human, not override). Server-side, enforceAiPracticeGate (P5, services/clinical/.../aiPractice/) mirrors enforceAhpGate’s 6 steps and wires the same three chokepoints the AHP README names (commitDiagnosis net-new action, orderRequest sign wrap, transfer accept).
4. The Autopilot Engine runtime
4.1 Dispatch → task → claim → act
department_queues INSERT/status-change
│ trg_opd_autopilot_dispatch (AFTER trigger, mirrors 20260529b)
│ guards: autopilot_runtime_state.kill_switch=false [V] → config row exists for
│ (station scope) → mode ∉ (off,assist) → rate cap not exceeded [V]
▼
opd_autopilot_tasks (status='pending', idempotency_key = queue_row_id‖station‖attempt)
│ claim_autopilot_task(p_station, p_capabilities) — FOR UPDATE SKIP LOCKED,
│ sets claimed_by/claimed_at/claim_expires_at = now()+300s [V]
▼
WORKER (same code on server node P3 / edge node P4)
│ 1. re-check kill_switch + entitlement (server plane)
│ 2. run deterministic red-flag detector FIRST — escalate ⇒ decision:='refer', AI skipped [V]
│ 3. run the station driver (LLM via fabric, or pure-deterministic e.g. S2)
│ 4. mode='suggest' → INSERT cowork_proposals {…, write_action: <skill contract ref>}
│ + agent_proposal ack (the shipped inbox renders it)
│ mode='auto' → call the SAME existing REST endpoint the manual button calls,
│ as service identity, with idempotency key; tag write
│ {source:'opd-autopilot', agent_id, task_id} [V]
│ 5. release_autopilot_task(task_id, status, result); audit to llm_audit_log(agent_id)
▼
reap-stale-claims cron (cron_jobs registry row): claim_expires_at < now() ⇒ status='pending' retry,
max_retries ⇒ status='failed' + ack to supervisor_role [V]
Design notes, each one a verifier finding:
- The trigger never calls HTTP (orchestrator invariant #7) — it only inserts task rows; LLM work happens in workers.
- Rate caps live in config:
opd_autopilot_configs.rate_limit_per_minute(default 10) +max_concurrent_tasks(default 1); the claim RPC returnsbackpressurewhen exceeded and logs it [V]. - Kill switch:
autopilot_runtime_state(feature_key, enabled, kill_switch, reason); checked in the trigger AND the claim RPC AND the worker;/admin/opd-autopilotgets an Emergency-Stop button that flips it and emits ahospital_eventsrow [V]. - Worker auth: a
medos-ai-workerservice account with a long-lived scoped JWT issued at deploy (rotated; stored at/opt/medos/.worker-token.jwton each node). The existinguser_id:'cowork-runner'string literal is the named anti-pattern — do not replicate [V]. Workers reach Supabase via PostgREST RPC with the service key (server node) or a scoped key (edge), and the medOS backend via the gateway over HTTPS.
4.2 Suggest mode: the missing executor (per-skill handlers) [V]
cowork_proposals.write_action exists but nothing consumes it — cowork-proposal-decide deliberately never writes the chart. The autopilot makes the executor real, as a registry with contracts, not a generic “run arbitrary REST on accept” (rejected — unbounded):
cowork_skill_handlers:skill_id→{write_endpoint, request_schema (JSON Schema), required_fields, undo_action, enabled}. Proposals are validated against the contract before the Accept button enables; invalid payloads surface field-level errors instead of limbo.cowork-proposal-decideextension: on accept, if the proposal’s skill has an enabled handler → invoke it idempotently (duplicateproposal_id⇒ skip), store{status, chart_write_id, error}in newcowork_proposals.write_result; failure leaves the proposal accepted-but-unapplied with a visible “manual re-entry required” warning — never a silent half-commit [V].- Every applied write is tagged
{source:'cowork_agent', agent_id, proposal_id}and emitsmanifest.ai.action_committed; voids emitmanifest.ai.action_voided(two new entries inevent-contract.ts) [V]. - First two handlers shipped:
doctor-assignment(→accept_assignment_recommendationRPC) andscreening-triage(→ queue-transition).diagnosis-draft(→POST /v2/diagnostic/diagnosiss) ships with P5. - Proposal expiry never auto-accepts. The safety verifier proposed SLA-timeout auto-acceptance; this design rejects it — silence-as-consent collapses suggest into auto and contradicts the recommender-first invariant and the AHP cosign semantics (expiry escalates). Instead:
sla_expires_at+ the existing ack escalation chain; expired proposals →status='expired', row stays manual. - Inbox hygiene [V]:
agent_proposalrendering gains thecowork.proposalentitlement guard; a cleanup migration cancels orphaned demo proposals;AckOrderTypeunion catches up with the DB CHECK (minor, included in 20260611e).
4.3 Station drivers (the heart) — manual path vs AI driver vs transition
Every driver’s output funnels into the SAME endpoints listed in the “manual” column. LOC lives in engine/stationDrivers.ts (pure) + worker glue.
| # | Station / state | Manual today (kept untouched) | AI driver | Auto-mode transition executed |
|---|---|---|---|---|
| S0 | Check-in wait-check-in |
kiosk/mobile/FormRegister; HN via global-sequence POST /sequence/next |
none needed (kiosk is already self-service); deterministic exact CID/HN match may auto-claim session (DocumentIntakeInbox rule); dialog-register-ai assists staff |
CheckinSession claim → queue ticket (existing) |
| S1 | Screening wait-screening |
nurse opens ScreeningV2, records vitals (recordObservation → CDS fires), saves, clicks next |
screening-triage: red-flag detector first; LLM use case opd.triage_screening over {complaint, vitals, age, history summary} → {acuity 1-5, suggested_dept, rationale_th/en} into screening_sessions + panel/proposal |
POST /v2/medication/encounterJourney/queue-transition wait-screening → wait-doctor |
| S2 | Doctor assign wait-doctor |
charge nurse assigns; or /admin/auto-assign suggest panel |
delegates to the existing engine: two surgical fixes — add WHEN 'consultation' THEN 'consultation' to the dispatcher CASE (20260529b:45-52) and implement already_assigned ranking (query encounter_journey_cache attending → rank that doctor #1, fallback next rule) — then it’s config (auto_assign_configs.consultation.mode) + DOCTOR staff_assignments seeds [V] |
accept_assignment_recommendation(rec_id,'auto-dispatcher') → department_queues.assigned_to + ack notify (all existing) |
| S3 | Consult doctor-in-process |
doctor at patient-profile; PUT /v2/administration/encounters/{id}/start already fired |
assist only: SmartDiagnosis prefill (and finally consuming the siloed useSmartDiagnosisPrefill on dialog mount), VoiceOrder, scribe |
— (human station by definition) |
| S4 | Diagnosis commit | diagnosis dialog → POST /v2/diagnostic/diagnosiss |
suggest: diagnosis-draft proposal (catalog-grounded ICD-10/SNOMED via the SmartDiagnosis adapters — no hallucinated codes, SeenMatches guard); auto (P5 envelope only): capped state, gates fire |
handler → POST /v2/diagnostic/diagnosiss with AI-source tag |
| S5 | Orders/Rx | order dialog → POST /v2/medication/orderRequests (8 safety checks server-side) → POST …/sign per resolveOrderReleaseGate |
suggest: order/rx draft proposal from dx + protocol (envelope drug_whitelist lookup); auto (P5): create + sign only whitelisted SKUs, gate commit_prescription[ai-autopilot] enforced |
the same two POSTs |
| S6 | Pharmacy pharmacy_screening/_dispense queues |
PUT …/updatescreened/:id → PUT …/qcVerification/:id → PUT …/:id/updateMedicationRequestStatus {completed} (emits emitPharmacyDispensed) |
suggest only: AI pre-verification annotation (re-run interaction/allergy summary + dose sanity via CDS engine, flag mismatches) attached to the worklist row; pharmacist accepts → updatescreened + qcVerification fire |
pharmacist clicks dispense; gate dispense_medication + scan results stay authoritative |
| S7 | Cashier billing queue |
trg_sync_billing_queue spawns row; cashier GenerateBill → POST /v2/financial/receipts/createPayments |
auto: charge materialization + queue advance + receipt pre-fill; wire the payment policy gate into GenerateBill confirm (usePolicyGate('receive_payment'), fail-open, config-gated — closes the verified today-gap of zero gates in cashier) |
payment confirm: human or gateway callback ONLY |
| S8 | Settlement | /revenue-settlement tabs; closeOPDDay |
suggest: draft claim batch + anomaly flags as proposals | human confirms in existing workspace |
5. Data model (new tables, migrations 20260611a–f)
All idempotent (CREATE TABLE IF NOT EXISTS / ON CONFLICT DO NOTHING), RLS-scoped, realtime only where a UI subscribes. Schema persists when the module is off (no down-migrations) but is inert.
| Migration | Contents |
|---|---|
20260611a_opd_autopilot_screening.sql |
screening_sessions (id, encounter_id, patient_id, location_id, vitals_snapshot jsonb, complaint_text, complaint_lang, red_flag_result jsonb, triage jsonb {acuity, suggested_dept, suggested_doctor, rationale, model, latency_ms}, status draft|committed|escalated, committed_by, source manual|ai-assisted, timestamps) + indexes (encounter, location+status) + RLS (authenticated read, service write) + realtime |
20260611b_opd_autopilot_engine.sql |
opd_autopilot_configs (scope_type/scope_id, station, mode CHECK off|assist|suggest|auto, constraints jsonb, rate_limit_per_minute int default 10, max_concurrent_tasks int default 1, active, priority; UNIQUE active scope×station) · opd_autopilot_tasks (idempotency_key UNIQUE, queue_row_id, encounter_id, station, status pending|claimed|completed|failed|backpressure, claimed_by, claimed_at, claim_expires_at, attempt, max_retries default 3, result jsonb, error) · autopilot_runtime_state (feature_key UNIQUE, enabled, kill_switch, reason, updated_by/at) · RPCs claim_autopilot_task (SKIP LOCKED + caps check) / release_autopilot_task · trigger trg_opd_autopilot_dispatch (guarded; never HTTP) · cron_jobs row autopilot-reap-stale-claims (disabled by default, registry pattern 036) |
20260611c_ai_runtime_nodes.sql |
ai_runtime_nodes (id, node_key UNIQUE, kind CHECK edge|server|cloud, base_url, site_id, models text[], phi_class CHECK onsite|in_region|cloud_ok, priority, status healthy|degraded|down|never, last_heartbeat, capabilities jsonb, enrolled_by, hmac_key_ref) · ai_node_liveness_v view (live/stale/failing — the connector_liveness_v pattern) · RPC resolve_llm_node(p_model, p_phi_class) (healthy ∧ model ∈ models ∧ phi-compatible, ORDER BY priority) · seed: one server row pointing at OLLAMA_URL so P0 behavior ≡ today |
20260611d_ai_practice_envelope.sql |
ai_practice_envelope + ai_capability_grant + ai_gate_log (§3) + 2 policy_gates rows commit_diagnosis/commit_prescription scoped care_settings:['ai-autopilot'] hard_stop + encounter_journey_cache.autopilot_context jsonb column |
20260611e_cowork_skill_handlers.sql |
cowork_skill_handlers (skill_id UNIQUE, write_endpoint, request_schema jsonb, required_fields text[], undo_action jsonb, enabled default false) · cowork_proposals + write_result jsonb, sla_expires_at · cleanup: cancel orphaned demo proposals/acks [V] · tenant RLS tightening on cowork_proposals [V] |
20260611f_auto_assign_consultation.sql |
dispatcher CASE arm consultation→consultation · already_assigned strategy implementation (attending lookup from encounter_journey_cache, rank 1, fallback to next rule when none) · dept_type inventory comment (enum hygiene, minor [V]) |
Market packs: seed-opd-autopilot-configs.sql per country — every row mode='off'; jurisdictions with no legal basis for clinical auto additionally seed ai_capability_grant rows with verdict='suggest' only (VN pattern). module.json postInstall inserts the disabled module_entitlements row (activation = a compliance event, not an install side-effect — AHP precedent).
6. The AI Runtime Fabric (“hypervirtualization”)
One logical AI runtime over N physical nodes. The unit of scheduling is the task, not the request: nodes pull work they are qualified for, so capacity, models, and data-residency all become per-node properties instead of deployment-wide constants.
6.1 Topology and tiers (honest about today)
| Tier | Hardware | What runs | Status |
|---|---|---|---|
| T0 server | medOS-ultra host (PH EC2 today; on-prem box via compose) | the existing ollama:11434 + api-llm; P3 adds the autopilotWorker mixin in services/llm |
exists — P1–P3 run entirely here; ai_runtime_nodes seeds exactly one server row |
| T1 edge | clinic mini-PC / workstation with GPU (user has physical hardware access) | medos-ai-node-agent container set: ollama + node agent (heartbeat upsert to ai_runtime_nodes every 30s — the telemetry-collector.sh pattern — and the same worker claim loop) |
P4 — NOT FOUND in codebase today; greenfield [V] |
| T2 cloud | optional burst (vLLM/openai-compat — llm_models.provider already supports it) |
non-PHI workloads only | P4+, optional |
Provisioning an edge box = ./infrastructure/scripts/setup-edge-ai.sh --site <clinic> --server https://medos.local (mirrors setup-onpremise.sh): installs docker, pulls the model manifest (per llm_models rows tagged for edge), generates the node HMAC key + scoped worker token, enrolls the node (HMAC-verified, the ingest-operational-facts pattern), joins the network (LAN direct or WireGuard/Tailscale to reach gateway + Supabase), writes the compose file, starts heartbeating. Disenrollment = mark node down + revoke token; tasks drain back to the server tier automatically.
6.2 Routing (resolve_llm_node) and the single hook point
llm_models.endpointUrl is already table-driven per model; the only change is ChatOrchestratorMixin resolving the node first:
use case (llm_use_cases.code) → required model + task phi_class
→ resolve_llm_node(model, phi_class): status='healthy' ∧ model ∈ node.models
∧ phi_compatible(task, node) ORDER BY priority DESC, last_heartbeat DESC
→ getOllamaProvider(node.base_url) (the existing per-URL singleton)
→ on transport error: mark liveness sample, retry next node; exhausted ⇒ task back to queue
PHI residency is enforced, not preferred [V]: phi_class='high' tasks (anything carrying identified clinical context) match only onsite/in_region nodes. If none is healthy the task queues and the station degrades to manual — it never silently falls back to a cloud node. The routing decision (node_selected, fallback_reason) is stamped into llm_audit_log per inference. Prompts pass the existing redaction.ts per the agent’s phi_masking_policy (strict = identifiers stripped, encounter-scoped refs only) [V].
6.3 Failure modes (the worklists never block)
| Failure | Behavior |
|---|---|
| All nodes down | tasks queue; suggest/auto stations silently behave like off; worklist buttons unaffected (invariant #0) |
| Edge node dies mid-claim | lease expires (300s) → reap cron returns task to pending → another node claims; idempotency key prevents double execution [V] |
| Duplicate delivery / retrigger | idempotency_key UNIQUE + handler-side duplicate-proposal/duplicate-write guards |
| Model hallucination wave | per-station rate caps bound the blast radius; Emergency-Stop kill switch halts claiming globally in <1 poll interval [V] |
| Stale model on edge | node heartbeat carries models[]; resolver simply stops routing to it; agent re-pulls per manifest |
6.4 Observability
Reuses the live substrate end-to-end: node heartbeats → ai_runtime_nodes + ai_node_liveness_v rendered in an HDAP AI Runtime panel (ConnectorActivityPanel pattern); per-inference llm_audit_log (+agent_id, node, latency); per-action manifest.ai.* events in hospital_events; per-gate ai_gate_log; acceptance analytics reuse the auto-assign Analytics tab pattern (accepted/rejected/expired per station — which is also the training label stream, the existing accept/edit/reject-as-label invariant).
7. Invariants (the 13 rules)
- Worklists always work. Kit off/dead/erroring ⇒ today’s manual flow byte-identical. Verified-decoupled: the trigger no-ops without config rows; the AI panel is an optional slot inside ScreeningV2’s layout, not a replacement.
- Never a new clinical write path. Workers and accept-handlers call the same REST endpoints the buttons call, with the same server-side validation (the 8 prescribe checks, release gates, dispense gates).
- Default-off on every plane (env flags, module_entitlements, configs, agents, handlers, cron rows).
- Server-enforced autonomy. Frontend mode toggles are advisory; the backend plane (entitlement helper +
enforceAiPracticeGate+ hard-stop policy gates) refuses what the deployment hasn’t licensed — refusal, never silent downgrade. - Deterministic red flags precede AI, always. Detector runs before any auto decision; escalate ⇒ refer and the AI output is discarded, logged, never merged.
- Suggest never auto-accepts. Expiry escalates or expires to manual; silence is not consent.
- Clinical auto only under a physician-signed AI practice envelope with capped output states (
FORBIDDEN_AHP_STATEScarry over: neverdiagnosed, neverprescribed_unrestricted). - Every AI action is attributed, audited, and undoable — agent identity on the write,
manifest.ai.action_committed/voidedevents,write_result+undo_actionper skill,ai_gate_logdistinguishing AI-recommended from human-decided. - Rate-capped and kill-switched at three layers (trigger, claim RPC, worker).
- PHI never silently leaves its residency class; high-PHI tasks queue rather than route to cloud; prompts pass redaction policy; routing decisions audited.
- Idempotent everywhere — task keys, claim leases, duplicate-write guards, ON CONFLICT.
- Payment confirmation is never AI-auto. Human or payment-gateway callback only.
- Accept/edit/reject is the training label (existing platform invariant; the proposal stream is the dataset).
- Country caps are data. A market pack can forbid
autowholesale (VN pattern); the engine downgrades mis-seeded rows and logs why.
8. Worked example — full-auto clinic visit (target state, P5, all gates green)
09:00 Patient scans kiosk QR → CheckinSession exact-HN match → ticket, dept screening [S0 auto-det.]
09:02 Nurse station: vitals device + STT complaint → recordObservation (CDS fires)
Red-flag detector: clear → triage task claimed by clinic edge box (PHI stays on-site)
Triage: acuity 4, URI, suggest GP queue → screening_sessions committed [S1 auto]
queue-transition wait-screening → wait-doctor
09:03 Dispatcher → auto-assign: already_assigned miss → least_busy GP → assigned_to,
doctor notified via ack [S2 auto]
09:10 GP consults; SmartDiagnosis prefill + VoiceOrder draft [S3 assist]
09:18 Dx J06.9 committed by GP (or, doctorless site: AI draft → remote cosign = Lane B) [S4]
Rx within envelope whitelist → orderRequests + sign (release gate: auto) [S5]
09:20 Pharmacy row pre-verified by AI (no interactions) → pharmacist accepts, dispenses [S6 suggest]
09:24 financial_summary → billing queue; receipt pre-filled; patient pays QR at cashier;
gateway callback confirms → receipt created [S7]
09:25 Encounter doctor-finish; nightly closeOPDDay; claim draft proposal for settlement [S8 suggest]
Every hop above is also a button a human can click; the autopilot config per station is what decided who moved it.
9. Rollout
| Phase | Scope | Hard gate |
|---|---|---|
| P0 (this doc) | Design + verifier corrections | — |
| P1 Screening kit — SHIPPED + sandbox-verified 2026-06-10 | 20260611a (screening_sessions + opd.triage_screening llm_use_cases seed — NOT yet applied to any Supabase) + kit @medical-kit/opd-autopilot (types / entitlement / triageEngine (parse+hallucination-drop+fallback+red-flag precedence) / autonomyLadder / ScreeningAIProposalPanel / ScreeningStationLayout / opdAutopilotModule footer adapter) + module.json + FeatureKey/FEATURE_PLANS rows (opd.autopilot, opd.autopilot.auto, cowork.proposal) + guarded store boot block + sandbox ?target=OpdAutopilotScreening (4 scenarios verified: clear→proposal, chest-pain→escalate wins over AI “fine”, hallucinated dept/doctor dropped, AI-down→deterministic fallback acuity 2) + 18 engine tests passing. No queue writes by AI — proposals render in-panel; nurse drives transitions |
zero behavior change off (flag-gated dynamic import); ScreeningV2 untouched |
| P2 Doctor-assign live — SHIPPED + verified 2026-06-10 | web/supabase/migrations/20260611f_auto_assign_consultation.sql (NOT yet applied): (a) already_assigned strategy implemented in resolve_assignment_recommendations via the §10 3-step fallback (queue assigned_to → clinical_context.encounter_context.assignedDoctorId → hoisted attending_doctor_id), null-tolerant fall-through to the generic DOCTOR pool scan; (b) dispatcher consultation→consultation CASE arm plus a promotion trigger trg_auto_assign_dispatch_promote (AFTER UPDATE OF dept_type — consultation rows are born screening and PROMOTED, so AFTER INSERT alone misses them; idempotency guards: assigned rows + rows with pending recs never re-dispatch). Verified in throwaway Postgres: 6/6 scenarios (context attending, promotion+hoisted col, pool fallback, idempotency, AUTO assigns rank-1, unmapped-dept no-op). + web/scripts/seed-doctor-staff-assignments.mjs (projects real doctor ids from encounter_journey_cache into staff_assignments DOCTOR rows, insert-missing). + wait-doctor surfacing: new null-gated rowDetailRenderer seam through ConfigDrivenTable→WorkflowConfigBasedTabs→ConfigDrivenWorkstation (undefined ⇒ byte-identical, runtime-proven at sandbox ?target=WorklistRowDetailSeam); app mount ConsultationAssignSuggestRow + useConsultationAssignMode (one mode fetch, fail-soft off) reusing the EXISTING AssignmentRecommendations panel. /admin/auto-assign consultation toggle already generic — zero code |
operational only; reversible; inert until the seeded consultation config leaves mode='off' |
| P3 Autopilot engine | 20260611b/e + worker mixin in services/llm + skill-handler executor + ProposalReview modal + /admin/opd-autopilot (mirror AutoAssignPage) + FlowPulse board + kill switch + S1/S2/S7-operational drivers (auto allowed), S4/S5 capped at suggest |
service-account auth pattern in place [V]; reap cron + idempotency tested |
| P4 Fabric multi-node | 20260611c live routing + node agent + setup-edge-ai.sh + HDAP AI-Runtime panel + PHI-residency enforcement tests | an edge box enrolled in a real clinic LAN; failover drill passes |
| P5 Clinical auto (envelope) | 20260611d + enforceAiPracticeGate wired (commitDiagnosis / order-sign wrap — the same three chokepoints as AHP P2) + envelope admin/signing flow + diagnosis-draft handler |
clinical validation + named supervising physician + SaMD determination per jurisdiction; no real patient before this |
| P6 Money depth | payment gate wiring in GenerateBill (config-gated, fail-open), closeOPDDay auto-draft, claim-draft proposals | finance sign-off |
| P7 Hardening | RUDS rules for autopilot anomalies, multi-tenant RLS pass, offline outbox re-gating, voice/face attestation binding on envelope signing | — |
Explicitly deferred / rejected: SLA auto-accept (rejected, §4.2) · generic execute-arbitrary-REST-on-accept (rejected; contracts only) · reusing ahp_capability_grant for the AI actor (rejected by schema audit) · IPD stations (until ConfigDrivenTable migration) · patient-facing self-triage (separate SaMD, same as AHP) · vending-machine auto-dispense at S6 (hardware program of its own).
10. Decisions (resolved 2026-06-10) + one external blocker
All former open questions are resolved; only #6 remains, and it is an external dependency, not a design question.
- Attending-doctor source for
already_assigned— RESOLVED (code-verified). The active-assignment source of truth the dispatcher already uses isdepartment_queues.assigned_to(set byaccept_assignment_recommendation()); the encounter-level doctor lives atclinical_context.encounter_context.assignedDoctorId/assignedDoctorName(written byhandleDoctorAssignedatencounter-orchestrator/index.ts:2355and bybuildEncounterContextfield-mapping at:1317), with hoisted first-class columnsattending_doctor_id/_name(migration055_ipd_ward_round_display_columns.sql, prefers IPDattendingDoctor*over OPDassignedDoctor*). 20260611f therefore ranksalready_assignedby a 3-step fallback: (a) livedepartment_queues.assigned_toon the patient’s open consultation row, (b)clinical_context.encounter_context.assignedDoctorId, © hoistedattending_doctor_id. Caveat the ranking must tolerate: revisits that never fired amanifest.queue.doctor_assignedevent have all three null — rank falls through to the normal strategies, never errors. - Service-account issuance — DECIDED: deploy-time secret first, minted JWT later. P1–P3:
AI_WORKER_TOKENenv on the worker (same posture as the existing HMAC edge-fn secrets); P4 (when edge boxes appear and per-node identity matters): newservices/authactionaaa.serviceAccount.issueminting short-lived per-node JWTs. Rotation: 90 days; kill = rotate env + restart (already covered by kill-switch layer 1). - Edge box reference hardware — DECIDED: Mac mini (M4 Pro, 48–64 GB) as the P4 pilot box. Rationale: silent + low-power for a clinic front desk, no GPU-driver ops burden, Ollama runs natively on Apple silicon, readily purchasable in TH. Alternate spec where macOS is undesirable: Beelink/Minisforum mini-PC + RTX 4060 Ti 16 GB. Model policy: edge runs ≤7B q4 only — triage classification targets a 3B-class instruct model (
qwen2.5:3b-instructq4 as default candidate; Thai-tunedtyphoonas eval alternate — run the P4 eval before locking), rationale text and anything heavier routes to the server tier (mistral:7b live on PH today). - Per-clinic triage rubric — DECIDED: data, not code. Seed a default ESI-like 1–5 rubric; the rubric itself is a
clinic_screening_configextension (triage_rubricJSONB: levels, labels TH/EN, routing hints) so a clinic can swap scales without a code change. screeningdept_type queue rows — DECIDED: workflow-state-only at P1. No newdepartment_queuesrows for screening (avoids queue noise; kiosk displays read workflow state). If a deployment wants a kiosk queue, addspawn_queue_row: truetoopd_autopilot_configslater — config row, no code fork.- Gateway payment callback — DEFERRED (external blocker, unchanged). No PromptPay/gateway integration exists; S7 stays capped at suggest-with-cashier-confirm until a real gateway integration project ships. This is invariant #x (payment never AI-auto) regardless.