Case Record Porting — Steer Document¶
Date: 2026-04-08
Author: Srikanth Donthi (CPO/CTO) + Claude Code session 34
Status: Design Complete — Not Yet Implemented
Companion spec: case-record-porting-feature.md
1. Problem Statement¶
Curaway's data is currently scoped inconsistently between objects, and the inconsistency is causing patient-visible bugs.
| Object | Storage scope | EHR rebuild query | Cross-case behavior today |
|---|---|---|---|
cases.ehr_snapshot JSONB |
Case (one per case) | Re-derived on every rebuild | Empty on new case |
fhir_resources (Conditions, Observations, MedicationStatements, AllergyIntolerances) |
Patient (no case_id column) |
Patient-wide — every active row | All FHIR data flows into every new case |
document_references |
Patient (no case_id column) |
Time-windowed via uploaded_at >= case.created_at proxy |
New cases only see docs uploaded after the case was created |
cases.comorbidities |
Case | Case-only | Empty on new case |
cases.workflow_state |
Case | Case-only | Empty on new case |
The result, observed in production: a new case for the same patient
silently inherits the patient's full FHIR history (medications, allergies,
diagnostic findings, lab observations from prior cases) without prompting,
even though the documents that produced those FHIR resources are
case-scoped via the timestamp proxy. The half-applied fix at
ehr_rebuild_service.py:84-88 already calls this out:
"Documents from previous cases for the same patient should not bleed into this case's EHR. Without a case_id column on document_references, we use uploaded_at >= case.created_at as a proxy."
But the same fix was never applied to the FHIR query immediately above it.
A second bug found while triaging: the existing
_check_existing_records() document-reuse function in
case_orchestrator.py reads the wrong field —
proc_reqs.get("required_documents", []). After Session 31 wired
procedure requirements to Neo4j, that field is empty for most procedures
(Neo4j returns required_tests, not required_documents). The function
silently returns [] every time and the "I found records from your
previous interactions" message has been dead code since Session 31.
This is why the user has never seen it fire in testing.
2. Decision: Model B (case-scoped) with explicit per-category port consent¶
Decision: Treat each case as a fresh per-procedure intake. Records from prior cases are never silently inherited. The patient explicitly consents to porting via a rich card with per-category checkboxes (all checked by default), and confirms each ported item is still accurate before it lands in the new case's EHR.
Why case-scoped, not patient-scoped¶
Cross-border medical travel platforms operate on per-procedure intake — each case is closer to a new admission packet than to a longitudinal patient record. Three reasons that's the right model for Curaway:
- Stale data has clinical risk. A medication discontinued months ago, a previous diagnosis that resolved, a lab value drawn under a different clinical context — silent inheritance turns these into false signals for the matching engine and the risk assessor.
- Procedure context matters. A patient's prior CABG records (cardiac labs, ECG, echo) don't carry over cleanly to a new TKR case. Some do (general blood work), most don't.
- Explicit consent is the honest UX. Patients understand "do you want to bring your old records?" better than they understand "your old records magically appeared in this case".
Why explicit, not silent¶
Even when a record IS valid for the new procedure, silent inheritance is the wrong UX:
- The patient may have updated information the system doesn't have (a medication discontinued last week, a lab redone yesterday).
- The patient should see what's being carried over so the relationship between cases is transparent.
- The agent can ask one focused question ("are these still accurate?") instead of re-collecting everything from scratch.
3. The Six Caveats¶
Caveat 1 — Ask before porting¶
When a new case starts and the patient already has records on file
(FHIR resources OR documents), the orchestrator pauses after
procedure identification and renders a port_records_consent rich
card. The card shows:
- A short header: "You have records from prior cases. Use them for this case?"
- One row per category that has at least one valid eligible record (after Caveats 2 + 5 filtering — see below)
- Each row: category name, item count, age range
- All rows pre-checked
- Two buttons: "Use selected records" (primary) and "Start fresh" (secondary, unchecks everything)
Replaces the dead _check_existing_records() document-only flow.
That function gets deleted in the implementation PR.
The card never appears when: - The patient has zero prior cases - Every prior record is filtered out by Caveats 2 + 5 - The new case has no procedure identified yet (the card waits for procedure ID)
Caveat 2 — Don't surface records outside their validity window¶
For each record on file, compute its age (today − record date).
Compare to validity_days from Neo4j REQUIRES_TEST for the new
case's procedure. If expired, don't surface it. The patient never
sees it on the card. Don't even mention it.
Examples (TKR, validity windows): - CBC validity = 30 days. A CBC drawn 25 days ago → eligible. A CBC drawn 45 days ago → not surfaced. - Chest X-ray validity = 180 days. An X-ray from 200 days ago → not surfaced. - ECG validity = 90 days. An ECG from 100 days ago → not surfaced.
Critical: validity is procedure-specific. The check is against the new case's procedure requirements, not the prior case's. A CBC valid for TKR (30d) might be expired for a procedure that requires 14-day blood work.
Default-allow for unknown record types. If a record's category
has no entry in the new procedure's REQUIRES_TEST list (e.g., the
patient ported a basic metabolic panel from a CABG case into a new
TKR case, and TKR's REQUIRES_TEST doesn't list "BMP" by name), the
record is still surfaced on the consent card with a "may not be
needed for this procedure" note. The patient decides. Default-allow
is the safer choice because it errs on the side of giving the patient
visibility — universally useful records (BMP, CBC, lipid panel)
shouldn't disappear just because the procedure-specific requirements
list doesn't enumerate every common test.
Hard-deny still applies for these cases: - Expired by validity window (Caveat 2 main rule) - Wrong laterality on imaging (left-knee X-ray for a right-knee TKR) - Wrong body system on imaging (chest X-ray for a knee procedure — unless the new procedure also requires chest imaging)
Each filtered-out record emits a port.dropped event with
drop_reason: "expired" | "wrong_laterality" | "wrong_body_system".
Records surfaced with the "may not be needed" note do not emit a
drop event — they emit a port.surfaced event with
procedure_match: "uncertain" so we can audit how often patients
choose to port them.
Caveat 3 — After porting, confirm every item¶
After the patient submits the consent card with a non-empty selection,
the orchestrator renders a second rich card: ported_records_review.
This card shows every ported item, grouped by category, with a
"Still accurate?" toggle on each row:
- Yes (default): the item lands in the new case as-is
- Stopped: the item is marked
status: discontinuedin the new case (not deleted — historical record preserved with the new status) - Changed: the item is dropped from the new case, and the patient is prompted to share the updated value
Patient hits Confirm to commit the choices. Until they do, the
matching gate (gates_v2) sees intake_complete=false and won't
advance.
The toggles are explicit because silent acceptance is the same problem we're trying to fix. The card is one round-trip — much faster than per-turn LLM confirmations.
Caveat 4 — Ask for fresher records after porting¶
After the review card is confirmed, the records-first phase fires with
biased framing: "I've ported these records: [list]. Anything more
recent we should add?" The records-first phase prompt
(llm_conversation.py records_first) gets a small addition that says
"if records were ported in this turn, lead with that confirmation".
Dedup: if the patient uploads a fresher version of an existing
record (e.g., a CBC from yesterday, replacing the 25-day-old one we
ported), the newer one wins. The dedup key is the procedure-required
test name (Complete Blood Count), not the filename. The older record
is marked superseded and disappears from the new case's view but
remains in the underlying FHIR table tied to its originating case.
Caveat 5 — JSONB extension, no schema migration¶
Per Gap #17 in gap-report.md, we don't have a
confidence column on fhir_resources and adding columns mid-MVP is
heavier than it's worth for synthetic data. Same applies here. Instead
of adding case_id columns to fhir_resources and document_references,
we store the originating case id inside the FHIR resource's
meta.extension:
{
"resourceType": "Condition",
"code": { ... },
"meta": {
"extension": [
{
"url": "http://curaway.ai/fhir/case-id",
"valueString": "case-018Cz5M71F15aFJ4Tbs63tAh"
}
]
}
}
The Clinical Context Agent store_resources node writes the
extension at FHIR creation time. The EHR rebuild query in
ehr_rebuild_service.py filters by reading the extension.
Old records without the extension are excluded from every case. Per SD's call (this is synthetic MVP data, not real patient data), records that pre-date the porting feature have no case attribution and the safest behavior is to exclude them entirely from new EHR rebuilds. They remain in the database (not deleted) and remain attached to whichever historical case wrote them, but the rebuild query only matches records with the explicit extension. The "silently inherit via timestamp proxy" fallback that the original spec proposed has been rejected — it would re-introduce the exact bleed-through bug we're fixing, just for old data.
Operational consequence: the first time a developer or test patient
loads a case after this PR ships, the EHR drawer will show empty
sections until new records are added. This is acceptable because
all current data is synthetic. When real patient data exists
post-Series A, we run a one-shot backfill that writes the extension
on every existing FHIR resource using its originating case (joinable
via the document → case linkage already present in
document_references).
The extension URL uses Curaway's namespace (http://curaway.ai/fhir/)
which is FHIR-compliant practice. When we eventually run a real
migration to add case_id as a real column, the extension contents
become the backfill source.
Caveat 6 — Validate that dropped records are actually safe to drop¶
Every time a record is dropped (because it's expired, doesn't apply
to the new procedure, or has the wrong laterality), emit a port.dropped
event:
{
"event_type": "port.dropped",
"case_id": "<new case>",
"patient_id": "<patient>",
"payload": {
"record_id": "<fhir resource id or document id>",
"record_type": "fhir_observation" | "fhir_condition" | "document",
"drop_reason": "expired" | "wrong_procedure" | "wrong_laterality",
"details": "CBC age=45d, validity=30d for TKR"
}
}
We can audit these in a Metabase dashboard once we have ≥50 cases through the porting flow. If we see a pattern of false drops (records that should have been ported but weren't), we tighten the rules.
Validation also happens in unit tests with fixture cases: - TKR → CABG: cardiac labs port; orthopedic imaging drops - TKR-left → TKR-right: blood work ports; left-knee imaging drops - TKR → TKR (same procedure, fresh case): everything within validity ports
4. Per-Category Consent Card¶
The port_records_consent rich card has up to 7 rows, one per
category. All checkboxes are pre-checked by default. The patient
unchecks any categories they want to skip and hits Submit.
| Category | What it includes | FHIR types matched |
|---|---|---|
| Medications | Active medications with dose + frequency | MedicationStatement |
| Allergies | Drug, food, environmental allergies | AllergyIntolerance |
| Conditions | Active comorbidities, prior diagnoses | Condition |
| Lab results | Blood work, urinalysis, panels | Observation (lab) |
| Imaging | X-rays, MRI, CT, ultrasound, echo | Observation (imaging) + DiagnosticReport |
| Diagnostic findings | Free-text findings from clinical notes | Observation (note) |
| Surgical history | Prior procedures with date + facility | Procedure |
Each row shows: - Checkbox (pre-checked) - Category name - Item count: "3 medications" - Optional detail line: "Metformin 500mg BID, Lisinopril 10mg daily, Aspirin 81mg daily" (truncated if long) - Age range: "from 12–25 days ago"
Submit button is enabled when at least one category is checked OR when the patient explicitly clicks "Start fresh" (which unchecks all and proceeds with no port).
After Submit, the rich card is replaced by a confirmation message and
the ported_records_review card (Caveat 3) takes over.
5. Interaction with existing systems¶
gates_v2 (Layer 1 conversation flow gates, PR #70)¶
The porting flow fits into the existing workflow as a step after
procedure identification and before records collection. The
intake_complete gate from gates_v2 is unaffected — porting just
populates extra_metadata.medications / allergies / etc. earlier
than they would otherwise arrive, which means intake_complete
fires sooner. That's a feature, not a bug.
Medical advice remediation (PR #84, draft)¶
The ported_records_review card lists clinical facts that came from
prior cases. These will appear in the EHR drawer Risk Assessment via
the risk_assessor.py rules, which have already been rewritten in
PR #84 to use descriptive 'providers typically...' framing. No new
medical-advice surface is introduced by porting — same risks, same
words. The tests/test_no_medical_advice.py CI guard from PR #84
covers any new strings introduced by the porting flow.
Risk assessor¶
Ported FHIR resources flow through the same risk_assessor.assess_risks()
pipeline as fresh resources. The risk assessor has no notion of
"ported vs fresh" — it just reads the EHR snapshot. This is correct
behavior: a risk is a risk regardless of which case it was first
detected in.
One subtle interaction: the "Stopped" toggle in the review card
sets status: discontinued on a medication. The risk assessor's
medication scan reads from medical_history.medications which today
doesn't filter by status. We need to make the scan honor status
(skip discontinued meds) — small change, included in the implementation
spec.
Clinical Context Agent¶
When the Clinical Context Agent's store_resources node writes a new
FHIR resource, it now also writes the meta.extension with the
originating case id (Caveat 5). One-line change in
app/agents/clinical_context.py. Backwards-compatible: resources
without the extension still load via the timestamp proxy.
6. Gap report references — what we can and cannot do¶
The porting spec is constrained by three known gaps in
gap-report.md. The spec is honest about each:
Gap #9 — EHR Builder is function-based, not a service class¶
"No merge rules, no conflict detection, no source priority. Multi-source data can overwrite silently; contradictory clinical data undetected."
Implication for porting: we cannot rely on a merge_clinical_entities()
method that doesn't exist. When a ported medication and a fresh
medication both name the same drug (e.g., "Metformin 500mg" ported
from a prior case and "Metformin 1000mg" mentioned in the new case
chat), there's no automated reconciliation.
Workaround: the ported_records_review card IS the conflict-resolution
UX. The patient sees both versions and chooses (Yes / Stopped / Changed).
This is a manual resolution, but it's correct for the MVP. Post-Series A,
when the EHR builder becomes a service class with merge rules, the
porting flow can fall back to automated reconciliation for non-conflicting
cases and reserve the card for actual conflicts.
Gap #17 — No confidence field on fhir_resources¶
"Can't implement spec'd merge rules (confidence delta > 0.3 flagging)."
Implication for porting: we can't auto-flag "this ported record disagrees with a fresh record by more than X confidence" because neither record has a confidence value to compare.
Workaround: the "Still accurate?" toggles in the review card are the human-in-the-loop substitute for confidence-delta flagging. The patient is the source of truth.
Gap #11 — Neo4j patient nodes not auto-synced from FHIR¶
"Patient graph nodes not updated when FHIR resources created. Graph-enhanced matching can't use patient-specific clinical data."
Implication for porting: when records are ported to a new case, the matching engine still reads the FHIR table directly (not Neo4j patient nodes) for clinical context. Porting works correctly for matching even with Gap #11 unfixed because matching never touches the graph patient nodes today anyway.
When Gap #11 is eventually fixed, the porting flow's meta.extension
case_id will be the right key for filtering Neo4j patient node updates
to the active case as well.
7. Out of Scope (this layer)¶
- Real schema migration. Adding
case_idcolumns tofhir_resourcesanddocument_referencesis the right long-term fix but not appropriate for synthetic MVP data. The JSONB extension approach is the bridge. - Procedure-fit weighting beyond laterality. A spec for "this CBC was ordered for cardiac evaluation, may not be ideal for orthopedic pre-op" is post-Series A. For now, validity windows and laterality are the only filters.
- Multi-procedure aggregation. A patient who has both an active
TKR case AND an active CABG case won't see records ported between
them — the porting check looks at prior closed cases, not parallel
active ones. (Closed =
status in {forwarded, closed}.) - Cross-tenant porting. A patient who switches tenants doesn't carry records over. Tenant isolation overrides everything.
- Per-record (not per-category) consent. The card has 7 category checkboxes, not N item checkboxes. Per-item granularity is post-MVP if patients ask for it.
- Auto-port mode. No "always port everything for this patient" flag. Every new case asks. The friction is the feature.
8. Feature Flag¶
Flag name: case_record_porting_v1
Default: true
Behavior when disabled: New cases continue to silently inherit
patient-wide FHIR data via the existing buggy path. The flag is the
kill switch if porting causes user-visible regressions during rollout.
A second flag, case_record_porting_legacy_check, defaults to false
and removes the dead _check_existing_records() function from the
records-first branch. Both flags ship together but are independently
toggleable for safety.
9. Success Criteria¶
Within one week of deploy, measured via Langfuse + the events table:
- Zero unprompted FHIR bleed-through. Every cross-case record
appearing in a new case's EHR can be traced to a
port.consentevent with the patient's explicit selection. port.droppedevents are spot-checked for false drops on a sample of 20 cases. Target: zero false drops, ≤5% false retains.- Patient confirmation rate >90% on the
ported_records_reviewcard (i.e., patients are actually using the toggles, not blindly hitting Confirm). gates_v2.intake_completefires faster for cases with ported records than for fresh-start cases (the win we're after).- No new
voice_complianceorno_medical_adviceCI failures triggered by the new strings introduced by the porting flow.
10. Rollback¶
The flag case_record_porting_v1 is the kill switch. Setting it to
false in Flagsmith reverts to the legacy patient-wide FHIR query
within one cache TTL (60s). No code redeploy required.
If an individual case is broken by the porting flow, the orchestrator
falls back to the legacy path on any exception in port_records_to_case().
The fallback is logged as a port.fallback event for triage.
11. References¶
- Companion feature spec:
case-record-porting-feature.md - Gap report:
gap-report.md— Gaps #9, #11, #17 - Conversation flow gates Layer 1 (
gates_v2):ai-steer/conversation-flow-gates-steer.md, PR #70 - Medical advice audit:
ai-steer/medical-advice-audit-steer.md, PR #84 (draft) - Existing dead code being replaced:
app/agents/case_orchestrator.py:_check_existing_records() - Existing buggy query being fixed:
app/services/ehr_rebuild_service.py:75-82(patient-wide FHIR query)