medOS ultra

OPD AI Autopilot

AI intake screening kit + per-station autopilot + edge-server AI runtime fabric, registration to settlement, recommender-first.

27 min read diagramsUpdated 2026-06-10docs/architecture/opd-ai-autopilot.md

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:

  1. domain-then-transition — domain REST call (e.g. PUT /v2/administration/encounters/{encounterId}/start) then POST /v2/medication/encounterJourney/queue-transition with {encounterId, targetWorkflowState, fromWorkflowState, note};
  2. queue-transition — the queue call only;
  3. dispatch-releasePOST /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

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) suggestpharmacist 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 — like ahp_scope_envelope plus: 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→retired with the same signed-requires-signer CHECK.
  • ai_capability_grant — like ahp_capability_grant but agent_slug (→ cowork_agents.slug) instead of role_key, verdict ∈ (suggest|auto|forbid), envelope_ref NOT NULL for auto, per-jurisdiction; market-pack seeds can forbid auto per country (the VN-invariant pattern — medos-vietnam seeds ship verdict='suggest' rows only, and the engine downgrades any mis-seeded auto exactly as the AHP gate evaluator does).
  • ai_gate_log — append-only like ahp_gate_log plus ai_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 returns backpressure when 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-autopilot gets an Emergency-Stop button that flips it and emits a hospital_events row [V].
  • Worker auth: a medos-ai-worker service account with a long-lived scoped JWT issued at deploy (rotated; stored at /opt/medos/.worker-token.jwt on each node). The existing user_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-decide extension: on accept, if the proposal’s skill has an enabled handler → invoke it idempotently (duplicate proposal_id ⇒ skip), store {status, chart_write_id, error} in new cowork_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 emits manifest.ai.action_committed; voids emit manifest.ai.action_voided (two new entries in event-contract.ts) [V].
  • First two handlers shipped: doctor-assignment (→ accept_assignment_recommendation RPC) and screening-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_proposal rendering gains the cowork.proposal entitlement guard; a cleanup migration cancels orphaned demo proposals; AckOrderType union 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/:idPUT …/qcVerification/:idPUT …/: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)

  1. 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.
  2. 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).
  3. Default-off on every plane (env flags, module_entitlements, configs, agents, handlers, cron rows).
  4. 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.
  5. Deterministic red flags precede AI, always. Detector runs before any auto decision; escalate ⇒ refer and the AI output is discarded, logged, never merged.
  6. Suggest never auto-accepts. Expiry escalates or expires to manual; silence is not consent.
  7. Clinical auto only under a physician-signed AI practice envelope with capped output states (FORBIDDEN_AHP_STATES carry over: never diagnosed, never prescribed_unrestricted).
  8. Every AI action is attributed, audited, and undoable — agent identity on the write, manifest.ai.action_committed/voided events, write_result + undo_action per skill, ai_gate_log distinguishing AI-recommended from human-decided.
  9. Rate-capped and kill-switched at three layers (trigger, claim RPC, worker).
  10. PHI never silently leaves its residency class; high-PHI tasks queue rather than route to cloud; prompts pass redaction policy; routing decisions audited.
  11. Idempotent everywhere — task keys, claim leases, duplicate-write guards, ON CONFLICT.
  12. Payment confirmation is never AI-auto. Human or payment-gateway callback only.
  13. Accept/edit/reject is the training label (existing platform invariant; the proposal stream is the dataset).
  14. Country caps are data. A market pack can forbid auto wholesale (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 kitSHIPPED + 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 liveSHIPPED + 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_toclinical_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 ConfigDrivenTableWorkflowConfigBasedTabsConfigDrivenWorkstation (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.

  1. Attending-doctor source for already_assigned — RESOLVED (code-verified). The active-assignment source of truth the dispatcher already uses is department_queues.assigned_to (set by accept_assignment_recommendation()); the encounter-level doctor lives at clinical_context.encounter_context.assignedDoctorId/assignedDoctorName (written by handleDoctorAssigned at encounter-orchestrator/index.ts:2355 and by buildEncounterContext field-mapping at :1317), with hoisted first-class columns attending_doctor_id/_name (migration 055_ipd_ward_round_display_columns.sql, prefers IPD attendingDoctor* over OPD assignedDoctor*). 20260611f therefore ranks already_assigned by a 3-step fallback: (a) live department_queues.assigned_to on the patient’s open consultation row, (b) clinical_context.encounter_context.assignedDoctorId, © hoisted attending_doctor_id. Caveat the ranking must tolerate: revisits that never fired a manifest.queue.doctor_assigned event have all three null — rank falls through to the normal strategies, never errors.
  2. Service-account issuance — DECIDED: deploy-time secret first, minted JWT later. P1–P3: AI_WORKER_TOKEN env on the worker (same posture as the existing HMAC edge-fn secrets); P4 (when edge boxes appear and per-node identity matters): new services/auth action aaa.serviceAccount.issue minting short-lived per-node JWTs. Rotation: 90 days; kill = rotate env + restart (already covered by kill-switch layer 1).
  3. 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-instruct q4 as default candidate; Thai-tuned typhoon as eval alternate — run the P4 eval before locking), rationale text and anything heavier routes to the server tier (mistral:7b live on PH today).
  4. Per-clinic triage rubric — DECIDED: data, not code. Seed a default ESI-like 1–5 rubric; the rubric itself is a clinic_screening_config extension (triage_rubric JSONB: levels, labels TH/EN, routing hints) so a clinic can swap scales without a code change.
  5. screening dept_type queue rows — DECIDED: workflow-state-only at P1. No new department_queues rows for screening (avoids queue noise; kiosk displays read workflow state). If a deployment wants a kiosk queue, add spawn_queue_row: true to opd_autopilot_configs later — config row, no code fork.
  6. 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.
Ask Anything