Skip to content

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.


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:

  1. 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.
  2. 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.
  3. 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:

  1. The patient may have updated information the system doesn't have (a medication discontinued last week, a lab redone yesterday).
  2. The patient should see what's being carried over so the relationship between cases is transparent.
  3. 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: discontinued in 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


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_id columns to fhir_resources and document_references is 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.consent event with the patient's explicit selection.
  • port.dropped events 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_review card (i.e., patients are actually using the toggles, not blindly hitting Confirm).
  • gates_v2.intake_complete fires faster for cases with ported records than for fresh-start cases (the win we're after).
  • No new voice_compliance or no_medical_advice CI 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