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.
| # | Path | Direction | Source | Built? |
|---|---|---|---|---|
| A | Manual prescription capture | EMR/clinic → MoM | a photo/PDF or typed copy of the script | YES |
| B | EMR connector (FHIR R4) | EMR → MoM | an EMR that does have an API | scaffold (disabled) |
| C | Embedded MoM UI (signed launch) | EMR frames MoM | the EMR's backend signs a token | YES (docs/emr-embed.md) |
| D | Write-back (Composition + DocumentReference) | MoM → EMR | an EMR write surface | dormant (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:
- A reader/connector output is a candidate, never authority.
- Only a clinician attestation makes a plan authoritative for reminders.
- A patient's reported use never overwrites the prescription (it is evidence).
- A stale plan freezes plan-derived reminders until re-attestation.
- 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
- 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.
- MoM reads it with the quarantined multimodal reader and returns a
candidate structured plan.
- The clinician reviews it (side by side with the source) and attests the
plan (reconcile).
- 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
| Piece | File | |
|---|---|---|
| Extraction schema (strict, fail-closed) | packages/contracts/src/prescription.ts | |
| Reader instructions + response schema | packages/backend/src/prescriptions/prompt.ts | |
| Extraction service (image \ | text → validated candidate) | packages/backend/src/prescriptions/extract.ts |
| Projection → the plan the loop reads | packages/backend/src/prescriptions/projection.ts | |
| Orchestration + idempotency + failure recording | packages/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 support | packages/ai/src/model-port.ts, packages/ai/src/adapters/gemini.ts | |
| Console capture screen | apps/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_snapshotsrow 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_aton 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
| Piece | File | Role |
|---|---|---|
| Slot derivation (pure) | packages/backend/src/reminders/slots.ts | regimen text → daily slots (morning/midday/evening/night); null when unsure |
| Planner (pure) | packages/backend/src/reminders/planner.ts | activated reminders + now + schedule → the due nudges, in the patient's timezone, idempotent per day |
| Service | packages/backend/src/reminders/service.ts | orchestration → idempotent outbox intent + dispatch_outbox job |
| Ports | packages/backend/src/reminders/ports.ts | the DB / R2 / job seams (injected; pg-backed in the worker) |
6.2 The three gates (all fail-closed)
- Activation — only rows the clinician attested into
reconciled_reminders
are ever considered.
- 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.
- 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:
| Rule | Value |
|---|---|
| Ordinary proactive check-ins | 1/day, 3/week, ≥24h apart (caps are ceilings, not targets) |
| Two unanswered check-ins | pause ordinary outreach — the patient is not a campaign metric |
| After downtime | stale check-ins are cancelled, never flushed as a burst |
| One occasion | one message (unique occasion key) |
| Every proactive send | nine 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
jobstable. 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
| Table | Migration | Role |
|---|---|---|
prescription_captures | 0030 | the raw capture (R2 key / typed text) + the reader extraction + state, linked to the snapshot it produced |
care_plan_snapshots | 0005 / 0017 | the authoritative recorded plan (structured_projection JSONB NOT NULL), the reconcile target, service_mode marker, source_version |
reconciled_reminders | 0020 | which reminders a clinician activated, and which snapshot justifies each |
outbox | 0004 / 0012 | a committed external effect; effect_type = 'send_whatsapp_message', unique logical_key, payload_ref, expected_versions |
jobs | 0004 | the durable work rows (dispatch_outbox), unique dedup_key, lanes, leases, retry |
messages | 0004 | outbound log: provider_id, delivery_state, seq, content_ref |
evidence_events / evidence_sources | 0005 / 0020 | what the patient said (reported use), with quotes — never edits the plan |
consents | 0003 | the consent record the dispatcher re-checks |
contact_bindings | 0003 | the encrypted recipient binding (encrypted_number BYTEA, active) |
conversations | 0004 | the per-(tenant, patient, binding) thread + versions |
human_takeovers | 0020 | pause automation when a human takes over |
model_runs | 0007 | the reader call’s metadata (task = prescription_reader) |
emr_integrations | 0029 | per-(tenant, EMR) embed signing secret + allowed origins |
mom_schema_migrations | 0000 | applied-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:
- The value (drug, dose, frequency) enters as
prescription_captures.extraction
(the validated PrescriptionExtraction JSON) — after Gemini reads the image/text.
- It is projected to
care_plan_snapshots.structured_projection.medications[]
with a stable ref (e.g. manual:metformin) and the human regimen.
- The clinician activates chosen refs into
reconciled_reminders. ReminderDispatchServicederives the slots fromfrequency/doseand
writes a template outbox intent named by the approved medication template.
dispatchOutboxrevalidates and callsChannelPort.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:
| Gate | Artifact | What it gates | If absent |
|---|---|---|---|
| Clinical policy pack | docs/clinical-policy-pack-template.md → approved pack | clinical thresholds, on-call roster, emergency wording, the reminder slot→time schedule | Layer B/C disabled; no reminders scheduled |
| WhatsApp template catalogue | approved template names | the template a proactive reminder may use | template_not_allowed — no send |
| Tone pack | M7 approved pack (psychology-baseline-v1 present) | the wording of patient-facing messages | message not composed |
| Jev pinned model | a pinned Jev version | the inbound semantic safety pass | process_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_idon 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_modeis 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_unknownand 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)
| Var | Used by | Purpose |
|---|---|---|
DATABASE_URL | app runtime | mom_app_runtime (RLS-enforced) |
MIGRATION_DATABASE_URL | migrator only | mom_migrator |
GEMINI_API_KEY | reader | construct the Gemini adapter (fail-closed if absent) |
ACCESS_TEAM_DOMAIN, ACCESS_AUDIENCES | staff routes | Cloudflare Access verification |
WEBHOOK_PAYLOAD_KEY, CONTACT_HMAC_KEY | worker | decrypt inbound + resolve sender |
KAPSO_* | channel | the 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
- 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.
- Review. The console shows the source beside the candidate; a low-confidence
row is flagged. Nothing is sent to the patient.
- Reconcile. Dr. Rao attests, marking
manual:metformineligible.
POST /v1/patients/P/care-plan-reconciliations sets reconciled_by/at (CAS) and writes reconciled_reminders(patient=P, snapshot=S, reminder_ref='manual:metformin').
- 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.
- Send.
dispatchOutboxrevalidates 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).
- 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
/docsrendering 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.tsregisters 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)
- The worker wiring is built.
apps/worker/src/index.tsnow 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.
- 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.
- 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.
build_briefadapters: four production adapters for the pre-visit brief.- The side-by-side verify screen (Ch.12 UI-020) and the fuller Ch.12 design
system.
- A live end-to-end capture→send proof against the real Gemini key + Neon
once (2) exists.
17. File & symbol map (quick index)
| Concept | Where |
|---|---|
| Extraction contract | packages/contracts/src/prescription.ts |
| Channel contracts | packages/contracts/src/channel.ts |
| Capture service | packages/backend/src/prescriptions/{service,extract,projection,prompt,pg-store}.ts |
| Reconciliation | packages/backend/src/clinical/reconciliation.ts |
| Reminder slots / planner / service / ports | packages/backend/src/reminders/ |
| Reminder pg store | packages/backend/src/reminders/pg-store.ts |
| Outbound dispatch | packages/backend/src/pipeline/dispatch.ts |
| Channel adapter (Kapso) | packages/backend/src/channels/kapso-port.ts |
| Job registry / executor | packages/backend/src/jobs/ |
| Worker entry | apps/worker/src/index.ts |
| Capture routes | apps/api/src/routes/prescriptions.ts |
| Clinical routes | apps/api/src/routes/clinical.ts |
| Console capture / reconcile / docs | apps/console/src/app/{capture,reconcile,docs}/ |
| Embed guide | docs/emr-embed.md |