Synthetic environment — not a clinical record.

MoM — Documentation

synthetic build · internal reference

MoM ↔ EMR Integration — the complete loop

Status: living engineering reference. This is the thorough document. It covers the WHOLE loop end to end: how a prescription value gets into MoM (when the EMR has no API), how it becomes a clinician-confirmed plan, how that plan drives WhatsApp communication, how the database is structured at every step, and how the MoM UI is embedded back into the EMR. What is built versus what remains is stated plainly in §16.

Audience: (a) MoM engineers, (b) the EMR vendor's team, (c) the clinic.

Owner direction this derives from: 2026-10-07 (embed the MoM UI; the EMR signs a launch token), 2026-10-08 (the prescription can be captured by image or written context, fed to the Gemini we use), and 2026-10-09 (the loop must be real: account for the depth of the database and the method by which prescription values reach the WhatsApp communication).


1. The problem this solves

A diabetes companion is useful only if it knows what the patient has been prescribed. Every medication reminder, every "did you take your metformin?", the reconcile screen, the pre-visit brief — all of it depends on a recorded plan.

The textbook way to obtain that plan is an EMR API (FHIR / SMART-on-FHIR). Our target EMRs — Pxcribe, Jiokivi, eka.care — expose no API and no integration wing. So MoM provides its own inbound paths and depends on no EMR vendor for the core loop.

#PathDirectionSourceBuilt?
AManual prescription captureEMR/clinic → MoMa photo/PDF or typed copy of the scriptYES
BEMR connector (FHIR R4)EMR → MoMan EMR that does have an APIscaffold (disabled)
CEmbedded MoM UI (signed launch)EMR frames MoMthe EMR's backend signs a tokenYES (docs/emr-embed.md)
DWrite-back (Composition + DocumentReference)MoM → EMRan EMR write surfacedormant (none exists)

Path A is the load-bearing one today. It is how a real prescription reaches MoM without any vendor cooperation.


2. The complete loop, end to end

 INBOUND (get the prescription value into MoM)
 ─────────────────────────────────────────────────────────────────────────────
  (A) MANUAL CAPTURE                         (B) EMR CONNECTOR (future)
  doctor photographs / types the script      FHIR fetchChanges (disabled)
         │                                            │
         ▼                                            ▼
  Gemini multimodal reader                    EmrService.importChanges
  (no tools; nulls, never guesses)            (normalize + bind identity)
         │                                            │
         ▼                                            ▼
  prescription_captures  ────┐          ┌────  care_plan_snapshots  (the plan)
  (raw source + provenance)  │          │       structured_projection JSONB
         │                   ▼          ▼       service_mode, source_version
         └────────────►  care_plan_snapshots ──┘
                                   │
 ──────────────────────────────────┼───────────────────────────────────────────
 RECONCILE (a clinician makes the plan authoritative)
                                   ▼
                    ┌── reconciled_by / reconciled_at (CAS on source_version)
                    │   attest the CURRENT regimen + effective period
                    ▼
             reconciled_reminders  (only derivable + clinician-eligible refs)

 ─────────────────────────────────────────────────────────────────────────────
 OUTBOUND (make it WhatsApp; this is the half that must be real)
                                   │
                                   ▼
             ReminderDispatchService  ── schedule from the approved pack
                                   │      slot from the regimen; inert if unapproved
                                   ▼
              outbox  (effect_type = 'send_whatsapp_message', idempotent logical_key)
                                   │
                                   ▼
              jobs   (job_type = 'dispatch_outbox', dedup_key)
                                   │   worker claim loop (queue + cron)
                                   ▼
              dispatchOutbox  ── revalidate consent/binding/window/takeover
                                   │
                                   ▼
              ChannelPort.send (Kapso)  ──►  WhatsApp Business  ──►  patient
                                   │
                                   ▼
              messages (provider_id, delivery_state) + audit_events

The safety spine the whole loop rests on:

  1. A reader/connector output is a candidate, never authority.
  2. Only a clinician attestation makes a plan authoritative for reminders.
  3. A patient's reported use never overwrites the prescription (it is evidence).
  4. A stale plan freezes plan-derived reminders until re-attestation.
  5. Every proactive send is a template and must be inside the governed gates

(see §10) — otherwise nothing is sent.


3. Path A — manual prescription capture

3.1 Who does what

  1. A clinic user opens MoM's standalone console (or the embedded UI, §12),

picks the patient, and either uploads a photo/PDF of the prescription or types/pastes it.

  1. MoM reads it with the quarantined multimodal reader and returns a

candidate structured plan.

  1. The clinician reviews it (side by side with the source) and attests the

plan (reconcile).

  1. Only then does the plan drive WhatsApp reminding.

3.2 The rule that matters most: the reader never invents

The extraction contract (packages/contracts/src/prescription.ts) is strict: every field the reader cannot actually read is null. A drug name it cannot read is not invented; an illegible dose is null; an unreadable date is null. If the image is not a legible prescription the reader sets unreadable = true and returns no medications — it never fabricates a plan.

Per-medication fields: name (required), strength, form, route, dose, frequency, duration, prn, instructions, sourceLine (verbatim), and a per-row confidence in [0,1].

3.3 The path in code

PieceFile
Extraction schema (strict, fail-closed)packages/contracts/src/prescription.ts
Reader instructions + response schemapackages/backend/src/prescriptions/prompt.ts
Extraction service (image \text → validated candidate)packages/backend/src/prescriptions/extract.ts
Projection → the plan the loop readspackages/backend/src/prescriptions/projection.ts
Orchestration + idempotency + failure recordingpackages/backend/src/prescriptions/service.ts
Postgres store (capture + snapshot in one transaction)packages/backend/src/prescriptions/pg-store.ts
HTTP routes (Access-gated)apps/api/src/routes/prescriptions.ts
Multimodal input supportpackages/ai/src/model-port.ts, packages/ai/src/adapters/gemini.ts
Console capture screenapps/console/src/app/capture/page.tsx

3.4 Multimodal input — why there is no separate OCR step

The owner's brief: "the prescription copy can be taken via images or written context which can be feeded to the gemini llm we are using." The image is fed directly to Gemini (which is multimodal) rather than running an OCR pass and then a text reader. ModelMessage gained an optional parts array; a text-only caller is byte-identical to before, and the adapter still emits no tools key. The gospel's ocr task remains the documented alternative if a non-multimodal reader is ever pinned.

3.5 Idempotency and failure semantics

  • content_hash = SHA-256 of the raw input. `UNIQUE (tenant_id, patient_id,

content_hash)` makes re-capturing the same source a no-op (returns the first capture), not a second plan.

  • A reader refusal/truncation/malformed output is recorded as

extraction_state = 'failed' with a non-PHI reason and no snapshot — the clinician sees an honest failure, never a silent empty plan.

  • The capture and its care_plan_snapshots row are written in one transaction,

so a capture can never exist without the plan the loop reads, or vice versa.


4. Path B — the EMR connector (scaffold, disabled)

For an EMR that does have an API, the seam already exists:

  • EmrPort — fetchChanges(cursor) + writeApprovedBrief(...)

(packages/backend/src/integrations/emr-port.ts).

  • importChanges(deps, { tenantId, cursor }) — normalizes each change, rejects

smuggled identity keys, schema-validates, binds the patient server-side (unresolved changes are counted, never guessed), and imports idempotently into care_plan_snapshots (packages/backend/src/integrations/emr-service.ts).

  • DisabledFhirConnector — compiles, refuses to construct without real

credentials, and reports writeBackSupport = { supported: false } (packages/backend/src/integrations/connectors/fhir-r4.ts).

Both inbound paths converge on care_plan_snapshots — and, as of 2026-10-09, on the same plan shape: normalizeEmrProjection converts a connector's medications[<id>] resources into the canonical structured_projection.medications[] array (ref + regimen) that the reconcile loop reads (planMedicationEntries). Before this, an EMR-imported plan met the captured plan at the table but not at the shape, so it could never reconcile — a real defect, now closed.

The production ingestion store exists: PgEmrSnapshotStore writes an imported plan into care_plan_snapshots idempotently, and PgEmrPatientResolver maps a connector change to a MoM patient through patient_identifiers (never from the payload). Remaining for Path B: the sync_emr producer/handler and a real EmrPort (an EMR with an API). See §16.


5. Reconciliation — the clinician act that makes a plan authoritative

Nothing derived from a plan reaches a patient until a clinician attests it.

  • Screen 4 (docs/gospel/12-console.md §12.8): the clinician reviews the

exact snapshot beside the patient's reported use — side by side, the prescription is never edited by patient data — and attests "this regimen and effective period are current".

  • The attestation writes reconciled_by / reconciled_at on the snapshot (CAS on

source_version) and activates only the reminders the clinician marked eligible and that are derivable from the reconciled plan (ReconciliationService, packages/backend/src/clinical/reconciliation.ts).

  • A reminder ref is derivable only if it matches (case-insensitively, in

either direction) a structured_projection.medications[].ref on the snapshot. A clinician cannot activate a reminder that the plan does not support.

  • Discrepancies are displayed, never auto-resolved. "I stopped the

glimepiride" is evidence in evidence_events; it does not edit the plan.

  • The route is POST /v1/patients/:id/care-plan-reconciliations

(apps/api/src/routes/clinical.ts), mounted in apps/api/src/index.ts.

  • The reconcile/verify screens' source read is GET /v1/patients/:id/care-plan

(same app): the latest snapshot plus the patient's reported use, side by side — displayed, never merged, with honest freshness (detection disabled when no approved boundary exists, §6.2.6). apps/api/src/routes/clinical.ts + packages/backend/src/clinical/care-plan.ts + apps/api/src/stores/pg-care-plan.ts.


6. The reminder send path (the half the owner found missing)

reconciled_reminders records which medication refs a clinician activated. Until 2026-10-09 nothing read that table — so no prescription value ever reached WhatsApp. This section is that path, now built at the service layer.

6.1 The pieces

PieceFileRole
Slot derivation (pure)packages/backend/src/reminders/slots.tsregimen text → daily slots (morning/midday/evening/night); null when unsure
Planner (pure)packages/backend/src/reminders/planner.tsactivated reminders + now + schedule → the due nudges, in the patient's timezone, idempotent per day
Servicepackages/backend/src/reminders/service.tsorchestration → idempotent outbox intent + dispatch_outbox job
Portspackages/backend/src/reminders/ports.tsthe DB / R2 / job seams (injected; pg-backed in the worker)

6.2 The three gates (all fail-closed)

  1. Activation — only rows the clinician attested into reconciled_reminders

are ever considered.

  1. Schedule — the slot→local-clock-time map comes from the **approved clinical

policy pack. Absent/empty ⇒ the planner returns nothing** and the loop is inert. MoM never guesses when to message a patient.

  1. Derivability — a medication whose regimen cannot be parsed to daily slots

is skipped entirely (deriveDailySlots → null).

Then the service refuses a send that is not an approved template (a proactive message is outside the 24h window; see §6.4) — again before writing anything. The reason is returned, never a silent no-op.

6.3 Idempotency, consolidation, and quiet hours

  • One nudge, not one per drug. Medications due in the same slot are

consolidated into ONE patient message (consolidateReminderPlans). Three morning medicines is one friendly message, never three — the difference between a companion and a pager.

  • Logical key: mom:nudge:<patientId>:<slot>:<localDate> — exactly **one

nudge per patient per occasion per patient-local day**, enforced by the outbox unique index. A retry, or a second medication row, cannot double-message.

  • Quiet hours (default 21:00–08:00 patient-local) are a hard ceiling: nothing

is scheduled inside that window.

  • Order: list → plan → consolidate → render → write the payload (R2, no

transaction) → read current versions → insert the intent (one idempotent transaction) → enqueue the dispatch_outbox job. No external call holds a DB lock (M2-066).

  • Midnight wrap: a slot whose window crosses local midnight keys to the day the

slot belongs to, so the 10th's nudge never collides with the 9th's.

6.4 Why proactive sends are templates

WhatsApp permits a business-initiated message only as a pre-approved template outside the 24-hour customer-service window. The dispatcher re-checks this (packages/backend/src/pipeline/dispatch.ts): a text intent outside the window is cancelled as window_expired. So a reminder is a template intent, and the template must be on the catalogue (isTemplateAllowed) or the service refuses it. The catalogue is owner/clinical-governed (§10), so the loop is structurally complete but inert until those artifacts exist — which is the honest state.

6.5 Dispatch revalidation (defense in depth)

Even after an intent exists, dispatchOutbox revalidates at send time and cancels rather than sending on drift: consent withdrawn, binding inactive, window expired, or an active human takeover / open urgent episode. expected_versions ({generationEpoch, ingestRevision, consentRows, bindingActive}) is snapshotted into the intent and compared before the send.

6.6 The companion, not a pager

Medication nudges are only one voice of the companion — and the least interesting. The product intent (owner, 2026-10-10) is a buddy: warm, personalized, habit-forming, health-tracking — not three identical "take your medicine" pings.

The gospel already specifies the discipline for unsolicited contact, and it is implemented in packages/backend/src/companion/proactive.ts:

RuleValue
Ordinary proactive check-ins1/day, 3/week, ≥24h apart (caps are ceilings, not targets)
Two unanswered check-inspause ordinary outreach — the patient is not a campaign metric
After downtimestale check-ins are cancelled, never flushed as a burst
One occasionone message (unique occasion key)
Every proactive sendnine hard send-time checks in code: opt-in, shared-device, quiet hours, binding, template/window, takeover, urgent episode, duplicate

The companion's topic planner (companion/planner.ts) chooses what to talk about by patient concern → barrier → preference ("address the newest concern first"), the tone pack shapes how, and turn-policy caps a message to one question. The buddy therefore lives mostly in the reactive conversation (where rich, personal, free-form replies are allowed), with warm, varied templates for the capped proactive nudges — because WhatsApp permits a business-initiated message only as an approved template.

Medication reminders are a separate, patient-consented schedule — they do not license exceeding the ordinary proactive budget, and they are consolidated, quiet-hours-bounded, and takeover/opt-out-aware. Habit/education/celebration nudges use the companion's capped proactive path, not the medication schedule.


7. The job pipeline & worker

  • Durable source of truth is the jobs table. A Cloudflare Queue message is

only a wake-up; a Cron sweep is the safety net, so a lost message is never a lost job (apps/worker/src/index.ts, sweepAllTenants).

  • Lanes (priority): 1 = intake + safety/deadlines, 2 = replies, 3 =

memory/reports. dispatch_outbox is lane 2.

  • Retry is per-type exponential backoff with jitter; exhaustion dead-letters

with a scrubbed alert. A job with no handler dead-letters as not_implemented — an unimplemented job never pretends to succeed.

  • The worker handler map is currently empty by design: the inbound pipeline’s

semantic safety pass needs the pinned Jev model, so process_inbound is not registered until that pin exists (§10). This does not apply to the reminder path — dispatching a plan-derived template needs no safety pass — so the dispatch_outbox handler + the reminder sweep can be registered independently (tracked; §16).

Job types (declared, packages/backend/src/jobs/registry.ts): process_inbound, extract_evidence, process_attachment, screen_safety, compose_reply, dispatch_outbox, build_checkpoint, rebuild_derivatives, check_escalation_deadline, sync_emr, schedule_checkin, build_brief, writeback_brief.


8. The database — depth

8.1 Tables on the loop

TableMigrationRole
prescription_captures0030the raw capture (R2 key / typed text) + the reader extraction + state, linked to the snapshot it produced
care_plan_snapshots0005 / 0017the authoritative recorded plan (structured_projection JSONB NOT NULL), the reconcile target, service_mode marker, source_version
reconciled_reminders0020which reminders a clinician activated, and which snapshot justifies each
outbox0004 / 0012a committed external effect; effect_type = 'send_whatsapp_message', unique logical_key, payload_ref, expected_versions
jobs0004the durable work rows (dispatch_outbox), unique dedup_key, lanes, leases, retry
messages0004outbound log: provider_id, delivery_state, seq, content_ref
evidence_events / evidence_sources0005 / 0020what the patient said (reported use), with quotes — never edits the plan
consents0003the consent record the dispatcher re-checks
contact_bindings0003the encrypted recipient binding (encrypted_number BYTEA, active)
conversations0004the per-(tenant, patient, binding) thread + versions
human_takeovers0020pause automation when a human takes over
model_runs0007the reader call’s metadata (task = prescription_reader)
emr_integrations0029per-(tenant, EMR) embed signing secret + allowed origins
mom_schema_migrations0000applied-migration bookkeeping

8.2 The row that matters

A single prescription_captures row links to one care_plan_snapshots row (connector = 'manual-capture', source_id = <capture id>, source_version = <content hash>), whose structured_projection.medications[].ref is what reconciliation resolves. A new version of a plan is a new snapshot row, never an edit. reconciled_reminders then points at that snapshot and a ref.

8.3 Tenant isolation

Every table above is ENABLE + FORCE ROW LEVEL SECURITY on tenant_id; an unset context returns zero rows. The app runtime role (mom_app_runtime) has no DELETE on the clinical and reminder tables (append/soft-state only) and no migration privileges. Migrations run as mom_migrator.


9. Where the “prescription values” actually live

This is the owner’s specific concern — the method by which prescription values reach WhatsApp. Concretely, for a captured script:

  1. The value (drug, dose, frequency) enters as prescription_captures.extraction

(the validated PrescriptionExtraction JSON) — after Gemini reads the image/text.

  1. It is projected to care_plan_snapshots.structured_projection.medications[]

with a stable ref (e.g. manual:metformin) and the human regimen.

  1. The clinician activates chosen refs into reconciled_reminders.
  2. ReminderDispatchService derives the slots from frequency/dose and

writes a template outbox intent named by the approved medication template.

  1. dispatchOutbox revalidates and calls ChannelPort.send (Kapso) → WhatsApp.

The drug name is used to schedule and to select a template; the dose wording in the message is the plan’s, never the model’s guess, and the “no dose invention” guarantee is enforced at the outbound safety layer (evaluateLayerC; see §10).


10. The governed gates (what is deliberately not code’s choice)

The loop is built so that the clinical and governance decisions live in artifacts, and the code fails closed without them:

GateArtifactWhat it gatesIf absent
Clinical policy packdocs/clinical-policy-pack-template.md → approved packclinical thresholds, on-call roster, emergency wording, the reminder slot→time scheduleLayer B/C disabled; no reminders scheduled
WhatsApp template catalogueapproved template namesthe template a proactive reminder may usetemplate_not_allowed — no send
Tone packM7 approved pack (psychology-baseline-v1 present)the wording of patient-facing messagesmessage not composed
Jev pinned modela pinned Jev versionthe inbound semantic safety passprocess_inbound not registered (dead-lettered as not_implemented)

This separation is the difference between “safe by construction” and “safe by hope”. Each refusal is recorded with a machine reason.


11. Security & privacy

  • Tenant isolation: tenant_id on every row; RLS ENABLED + FORCED.
  • PHI stays out of URLs and keys. The capture image is an encrypted R2 object;

only its opaque key is stored. Model/agent keys and connection strings are never logged.

  • No model-supplied identity. The reader supplies no tenant_id, patient_id,

or timestamps; server code binds them.

  • Synthetic vs live. service_mode is copied onto the snapshot, so a synthetic

capture can never be mistaken for a live prescription.

  • Audit: attestations, reminder activations, outbox transitions, and capture

state changes are recorded with actor + time + reason (audit_events).

  • Delivery: an ambiguous provider outcome is delivery_unknown and is

reconciled, never blind-resent.


12. Embedded UI (Path C)

The EMR frames https://<console>/embed?launch=<token>. The EMR’s backend signs a short-lived, single-patient token (HS256, ids only, ≤300s); MoM verifies it fail-closed and re-checks the doctor’s care-team entitlement. This is the signed-iframe substitute for SMART-on-FHIR. Full guide: docs/emr-embed.md.


13. Operations

13.1 Migrations

# as mom_migrator (never the app role); env loaded by the runtime, never opened
APP_ENV=staging node --env-file=.env.local infra/scripts/migrate-as-migrator.mjs

Pending at time of writing: none — 0000–0030 are live on Neon.

13.2 Live probes

node --env-file=.env.local infra/scripts/probe-m6-parity.mjs
node --env-file=.env.local infra/scripts/probe-m6-embed-capture-parity.mjs   # 0029/0030
node --env-file=.env.local infra/scripts/probe-m3-parity.mjs

13.3 Configuration (never committed)

VarUsed byPurpose
DATABASE_URLapp runtimemom_app_runtime (RLS-enforced)
MIGRATION_DATABASE_URLmigrator onlymom_migrator
GEMINI_API_KEYreaderconstruct the Gemini adapter (fail-closed if absent)
ACCESS_TEAM_DOMAIN, ACCESS_AUDIENCESstaff routesCloudflare Access verification
WEBHOOK_PAYLOAD_KEY, CONTACT_HMAC_KEYworkerdecrypt inbound + resolve sender
KAPSO_*channelthe WhatsApp BSP

14. For the EMR vendor / clinic

  • Path A needs nothing from the EMR vendor. No API, no FHIR, no marketplace.

A clinic user uploads the prescription in MoM’s console.

  • Path C (embedded UI): one <iframe> + one signed token from your backend —

see docs/emr-embed.md for the claims, the snippet, and a no-dependency signer.

  • Path B/D (future EMR API): we implement against EmrPort; you provide FHIR

R4 (or HL7v2), auth, and a DPA/region (DPDP India).


15. Worked example — one reminder, start to finish

  1. Capture. Dr. Rao photographs "Tab Metformin 500mg 1-0-1 x 30d" for patient

P. POST /v1/patients/P/prescription-captures (image) → Gemini → extraction {name:"Metformin", strength:"500 mg", dose:"1 tablet", frequency:"twice daily"} → one care_plan_snapshots row (connector='manual-capture', source_version=<hash>, structured_projection.medications=[{ref:"manual:metformin", regimen:"1 tablet twice daily", …}]) + one prescription_captures row.

  1. Review. The console shows the source beside the candidate; a low-confidence

row is flagged. Nothing is sent to the patient.

  1. Reconcile. Dr. Rao attests, marking manual:metformin eligible.

POST /v1/patients/P/care-plan-reconciliations sets reconciled_by/at (CAS) and writes reconciled_reminders(patient=P, snapshot=S, reminder_ref='manual:metformin').

  1. Schedule. At 08:00 patient-local, the next sweep calls

ReminderDispatchService.runForTenant. deriveDailySlots("twice daily") → ['morning','night']; the approved pack maps morning → 08:00; the 08:00 slot is due → it writes an idempotent outbox intent (logical_key = mom:reminder:S:manual:metformin:morning:2026-10-09) and enqueues dispatch_outbox.

  1. Send. dispatchOutbox revalidates consent/binding/window/takeover and calls

ChannelPort.send with the approved medication template → WhatsApp → the patient. The outcome is recorded on messages, and an ambiguous outcome is delivery_unknown (reconcile, never blind-resend).

  1. Freeze. If the plan later goes stale or the patient withdraws consent,

planRemindersFrozen pauses the reminders until re-attestation.


16. Honest status — built vs remaining

Built and green

  • Inbound Path A (capture): multimodal Gemini input; the strict extraction

contract; the reader prompt; the extraction service; the projection; the capture service (idempotency + failure recording); the prescription_captures table + Postgres store; the HTTP routes; the console /capture screen.

  • Reconcile: the M3 clinical surface is mounted; `POST .../care-plan-

reconciliations is reachable; the console /reconcile` screen attests and activates.

  • Reminder send spine (new 2026-10-09): the pure slot derivation, the

planner (timezone-aware, idempotent, midnight-safe, fail-closed), the dispatch service (idempotent outbox intent + dispatch_outbox job), and the pg-backed store (PgReminderStore) that reads activated reminders through the real schema and writes the outbox + jobs rows. Proven against Postgres (PGlite) end to end.

  • Docs section: the standalone console serves /docs rendering this file and

emr-embed.md from the repo.

  • EMR connection (Path B), 2026-10-09: the production PgEmrSnapshotStore

(idempotent import into care_plan_snapshots) + PgEmrPatientResolver (server-side identity via patient_identifiers), proving importChanges can actually write. Includes normalizeEmrProjection, which converges the EMR plan onto the same shape the reconcile loop reads — an EMR-imported plan can now activate reminders exactly like a captured one.

  • Worker wiring (2026-10-09): apps/worker/src/index.ts registers the

dispatch_outbox handler (guarded) and a cron reminder sweep; proven by apps/worker/test/handlers.test.ts.

  • Database: migrations 0000–0030 live on Neon; 0029/0030 applied 2026-10-09

and probed 17/17.

Evidence: packages/backend/test/prescription-capture.test.ts (11), packages/ai/test/gemini-multimodal.test.ts (3), packages/backend/test/reminders.test.ts (15), apps/api/test/route-mount.test.ts (3), apps/api/test/prescriptions-routes.test.ts (8), apps/console/test/markdown.test.ts (6).

Remaining (each a bounded, named task; none needs the EMR vendor)

  1. The worker wiring is built. apps/worker/src/index.ts now registers the

dispatch_outbox handler (guarded by the payload store + WEBHOOK_PAYLOAD_KEY + complete Kapso credentials) and runs a cron reminder sweep over tenants. The number DEK is the domain-separated deriveTenantNumberDek (see REMIND-GOV-2). Still to add: sync_emr registration (needs a real EmrPort). What actually gates a live send is now purely the governed inputs (REMIND-GOV-1) plus real channel credentials — no code remains between the built loop and a send.

  1. The governance artifacts (§10): the approved clinical policy pack (which

supplies the reminder schedule), the WhatsApp template catalogue, and the Jev pin. Until these exist the loop is deliberately inert.

  1. The EMR connector (Path B): the store + resolver + shape-normalizer are

built; remaining is the sync_emr producer/handler (job-table-as-cursor) and a real EmrPort once an EMR with an API exists.

  1. build_brief adapters: four production adapters for the pre-visit brief.
  2. The side-by-side verify screen (Ch.12 UI-020) and the fuller Ch.12 design

system.

  1. A live end-to-end capture→send proof against the real Gemini key + Neon

once (2) exists.


17. File & symbol map (quick index)

ConceptWhere
Extraction contractpackages/contracts/src/prescription.ts
Channel contractspackages/contracts/src/channel.ts
Capture servicepackages/backend/src/prescriptions/{service,extract,projection,prompt,pg-store}.ts
Reconciliationpackages/backend/src/clinical/reconciliation.ts
Reminder slots / planner / service / portspackages/backend/src/reminders/
Reminder pg storepackages/backend/src/reminders/pg-store.ts
Outbound dispatchpackages/backend/src/pipeline/dispatch.ts
Channel adapter (Kapso)packages/backend/src/channels/kapso-port.ts
Job registry / executorpackages/backend/src/jobs/
Worker entryapps/worker/src/index.ts
Capture routesapps/api/src/routes/prescriptions.ts
Clinical routesapps/api/src/routes/clinical.ts
Console capture / reconcile / docsapps/console/src/app/{capture,reconcile,docs}/
Embed guidedocs/emr-embed.md

Embedding MoM in your EMR (signed launch)

For the EMR team. MoM is embedded as a read-only panel inside your EMR with one <iframe> and one signed token. You do not need FHIR, SMART-on-FHIR, or an app marketplace — just the ability to (a) render an iframe and (b) sign a short-lived token from your backend.

Direction [OWNER 2026-10-07]: MoM UI inside the EMR (standalone + embedded).

The two UIs

UIURLAuth
Standalonehttps://console.mom.example/your doctor signs into MoM directly (Cloudflare Access / staff OIDC)
Embeddedhttps://console.mom.example/embed?launch=<token>a signed launch token from your backend

The flow (embedded)

Your EMR (doctor opens the MoM panel)
   │  your BACKEND signs a token: { iss, aud, tenantId, patientId, staffId, iat, exp, jti }
   ▼
<iframe src="https://console.mom.example/embed?launch=<token>">
   │
   ▼
MoM verifies the signature, expiry and issuer, then renders the panel
scoped to that ONE patient.

Nothing else crosses the boundary beyond opaque ids. The token carries no PHI.

What your backend does (once)

1. Register with MoM

We give you, for your clinic (tenant): an emr_id (e.g. pxcribe) and a shared signing secret (rotate on request). We record the origins allowed to embed (allowed_origins).

2. Sign a launch token per panel-open

A JSON Web Token, HS256, using your secret. Claims:

ClaimMeaning
issyour emr_id
audliteral "mom-embed"
tenantIdthe clinic (UUID)
patientIdthe ONE patient this panel may view (UUID)
staffIdthe doctor this panel is on behalf of (UUID)
iat / expepoch seconds; exp - iat ≤ 300 s
jtia unique id (replay/audit)

Node example (no dependency):

import { createHmac } from 'node:crypto';
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
function launchToken({ emrId, tenantId, patientId, staffId, secret }) {
  const now = Math.floor(Date.now() / 1000);
  const header = b64({ alg: 'HS256', typ: 'JWT' });
  const payload = b64({ iss: emrId, aud: 'mom-embed', tenantId, patientId, staffId, iat: now, exp: now + 120, jti: crypto.randomUUID() });
  const sig = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
  return `${header}.${payload}.${sig}`;
}

3. Render the iframe

<iframe
  src="https://console.mom.example/embed?launch=${token}"
  title="MoM"
  sandbox="allow-scripts allow-same-origin allow-forms"
  referrerpolicy="no-referrer"
  style="width:100%;height:100%;border:0"></iframe>

Generate a fresh token each time the panel opens, and keep it short-lived.

What MoM enforces (so you can rely on it)

  • The signature is verified server-side before anything renders; alg:none

and a tampered payload are refused.

  • The token is single-patient and ≤5 minutes; a leaked URL is a bounded

exposure, not a breach.

  • allowed_origins drives frame-ancestors — only your registered origin may

embed MoM.

  • MoM re-checks care-team authorization with the ids; the token proves your

assertion, not the doctor's entitlement.

The launch URL is PHI-equivalent [ARCH §2.10] — and is treated so

The token travels in the query string, and [ARCH §2.10] says "URLs and query strings are treated as PHI-equivalent" (the patient id is in the token). So:

  • the iframe carries referrerpolicy="no-referrer" (in the snippet above) so the

URL does not leak to a third party;

  • the console/API responses are Cache-Control: no-store, and the launch URL must

never be logged — query strings are scrubbed like PHI;

  • the token is short-lived and single-patient, so even a leaked URL is bounded.

OWED (recorded): a one-time-code exchange — the EMR puts an opaque code in the URL and the API trades it for the session server-to-server — would keep the token out of the URL entirely. It does not change what the EMR team implements today.

What MoM never does through this path

  • Never renders raw conversation to the EMR.
  • Never writes AI-inferred content into the EMR record (write-back stays gated; it

emits only a clinician-approved Composition + DocumentReference, and only if a future connector supports it).

  • Never accepts a token from an unregistered/disabled emr_id.

OWED (recorded, not hidden) — the EMR team is not blocked by these

  1. Encryption-at-rest for the stored signing secret (currently a reversible

column by necessity — HS256 needs the same key).

  1. Ed25519 (asymmetric) upgrade: you sign with a private key, MoM stores only

your public key — no shared secret. This is stronger and is the planned v2.

  1. The dynamic frame-ancestors is wired in the console (Chapter 12) from

allowed_origins; today the standalone console is frame-ancestors 'none', so /embed is the only embeddable route.