Case Record Porting — Feature Spec (v2)¶
Status: Implemented — shipped 2026-04-13, PRs #152 (backend) + #35 (frontend)
Spec version: v2 (rewritten 2026-04-10, all 12 audit findings resolved)
Companion steer: ai-steer/case-record-porting-steer.md
Overview¶
A patient creating a new case for the same patient identity should not silently inherit FHIR data from prior cases. They should see a consent card listing what's available, choose what to port (per-document, with checkboxes), confirm, and then continue into records collection. Records outside their validity window or wrong-procedure are filtered before the consent card is shown.
Ships behind a Flagsmith flag (case_record_porting_v1, default true)
with a fallback path that reverts to today's behavior on error.
What changed from v1¶
| # | Audit finding | Fix in v2 |
|---|---|---|
| 1 | meta.extension JSONB approach was speculative — fhir_resources.case_id column already exists |
All queries use case_id column directly. No JSONB extension anywhere. |
| 2 | B4 deleted _check_existing_records() — already removed in Session 38 |
Removed from spec. Removed case_record_porting_legacy_check flag. |
| 3 | No Alembic migration for document_references.case_id |
Migration added (Part A0). |
| 4 | EHR rebuild document scoping used timestamp proxy | Updated to use case_id column (Part D1). |
| 5 | Missing WHERE status = 'active' on find_portable_records FHIR query |
Added (Part A1). |
| 6 | No superseded document exclusion from consent card | Added filter in find_portable_records (Part A1). |
| 7 | Missing port_consent_offered / port_consent_given workflow state fields |
Added to orchestrator logic (Part B1). |
| 8 | No documentation of cross-case document scoping interaction (PR #136) | Documented in Part D2. |
| 9 | Frontend paths referenced wrong repo | All paths now reference curaway-health-navigator/. |
| 10 | No PortRecordsConsentCard UI design | Full card design in Part F1. |
| 11 | No EHR drawer behavior after porting | Designed in Part F3. |
| 12 | No conversation flow design | Designed in Part B1 + "Conversation Flow" section. |
Conversation Flow¶
When does the porting prompt appear?¶
After procedure identification, before the records-first phase. The
orchestrator checks for portable records from prior closed cases. If
any eligible records exist, it sends the PortRecordsConsentCard rich
card instead of immediately asking for new documents.
What if the patient declines?¶
Two paths:
- "Start fresh" button — skips porting entirely. Sets
port_consent_given: false in workflow state. Flow continues to
records-first (asks for new documents as normal).
- Uncheck all + submit — same behavior as "Start fresh".
What if the patient accepts some but not all?¶
Partial port. Only checked documents are ported. Unchecked documents
are logged as port.dropped with reason patient_declined. Flow
continues to records-first, where the agent knows which records were
ported and asks only for what's still missing.
What if ported records are expired?¶
Records past their validity_days are excluded from the consent card
entirely (hard drop in compute_eligibility). Records near expiry
(within 20% of validity window) are shown with a warning badge:
"Expires in N days — provider may request a new one." The patient can
still port them, but the agent flags them in the records-first phase.
File-by-file change list¶
Backend¶
| File | Change |
|---|---|
NEW alembic/versions/xxxx_add_case_id_to_document_references.py |
Alembic migration: add case_id column to document_references. |
NEW app/services/case_porting_service.py |
Porting service: find_portable_records(), compute_eligibility(), port_records_to_case(), _emit_port_event(). |
app/agents/case_orchestrator.py |
Insert porting flow after procedure ID, before records-first. New rich content type port_records_consent. |
app/services/ehr_rebuild_service.py |
Document query uses case_id column instead of timestamp proxy. FHIR query adds case_id filter. |
app/services/risk_assessor.py |
Medication scan honors status: discontinued (skip discontinued meds). |
config/feature_flags.yaml |
One new flag: case_record_porting_v1 (default true). |
app/services/decision_recorder.py |
New event types: port.consent, port.dropped, port.surfaced. |
Frontend (all paths in curaway-health-navigator/)¶
| File | Change |
|---|---|
NEW curaway-health-navigator/src/components/ehr/PortRecordsConsentCard.tsx |
Consent card with per-document checkboxes. |
curaway-health-navigator/src/pages/ConversationApp.tsx |
Register port_records_consent rich content type in MessageBubble. |
curaway-health-navigator/src/components/ehr/FullEHRDrawerV2.tsx |
DocumentsSection shows "Ported from CRW-XXXX" badge on ported documents. |
curaway-health-navigator/src/services/caseApi.ts |
New method: submitPortConsent(caseId, selectedRecordIds). |
Tests¶
| File | Change |
|---|---|
NEW tests/test_case_porting_service.py |
12 unit tests. |
NEW tests/test_case_porting_e2e.py |
2 integration tests. |
PART A — Data Layer¶
A0. Alembic migration: document_references.case_id [Sonnet]¶
Add a case_id column to document_references to match the existing
column on fhir_resources. This is the prerequisite for case-scoped
document queries.
# alembic/versions/xxxx_add_case_id_to_document_references.py
def upgrade():
op.add_column(
"document_references",
sa.Column("case_id", sa.String(36), nullable=True, index=True),
)
def downgrade():
op.drop_column("document_references", "case_id")
Also update the DocumentReference model in app/models/document.py:
case_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
# The case active when this document was uploaded.
Backfill script (run once after migration):
UPDATE document_references dr
SET case_id = (
SELECT c.id FROM cases c
WHERE c.patient_id = dr.patient_id
AND c.tenant_id = dr.tenant_id
AND dr.uploaded_at >= c.created_at
ORDER BY c.created_at DESC
LIMIT 1
)
WHERE dr.case_id IS NULL;
After backfill, the upload flow (confirm_upload in documents.py
router) must also write case_id from the request context. This is a
one-line addition to the confirm endpoint.
A1. find_portable_records(db, patient_id, tenant_id, current_case) [Opus]¶
Returns a list of PortableRecord candidates from the patient's prior
closed cases (status in {forwarded, closed}). Each candidate is a
FHIR resource OR document from a prior case.
class PortableRecord(TypedDict):
record_id: str # FHIR resource ID or document ID
record_type: Literal[
"fhir_condition", "fhir_observation_lab", "fhir_observation_imaging",
"fhir_medication", "fhir_allergy", "fhir_procedure",
"fhir_diagnostic_report", "document"
]
category: Literal[
"medications", "allergies", "conditions", "lab_results",
"imaging", "diagnostic_findings", "surgical_history"
]
name: str # e.g. "Metformin 500mg BID", "Blood Work — CBC from 2026-03-15"
document_name: str | None # original_filename of source document
observation_count: int | None # number of observations in document's extracted_data
raw_data: dict # the FHIR resource_json or document row
source_case_id: str
source_case_number: str
uploaded_at: str | None # ISO date for documents
age_days: int # today - record date
laterality: str | None # left | right | bilateral | None
FHIR query — uses the case_id column (already exists on
fhir_resources), NOT meta.extension JSONB:
fhir_result = await db.execute(
select(FHIRResource).where(
FHIRResource.patient_id == patient_id,
FHIRResource.tenant_id == tenant_id,
FHIRResource.case_id.in_(prior_case_ids),
FHIRResource.status == "active", # Audit fix #5
)
)
Document query — uses case_id column (added in A0), excludes
superseded documents:
doc_result = await db.execute(
select(DocumentReference).where(
DocumentReference.patient_id == patient_id,
DocumentReference.tenant_id == tenant_id,
DocumentReference.case_id.in_(prior_case_ids),
DocumentReference.is_deleted == False, # Audit fix #6
DocumentReference.analysis_status == "completed",
)
)
Superseded FHIR resources (status != 'active') are excluded by the
status == 'active' filter. Superseded documents (re-uploads that
caused fhir_service to mark old FHIR rows as superseded) are
excluded transitively: if all FHIR resources from a document are
superseded, that document is excluded from the consent card even if
the document row itself is not deleted.
A2. compute_eligibility(records, current_case) [Opus]¶
For each PortableRecord, decide eligibility for the current case.
Returns (eligible_records, dropped_with_reasons).
Hard-drop reasons:
- expired — record age > validity_days for this test type
- wrong_laterality — imaging/procedure for a different side
- wrong_body_system — imaging for unrelated body system
Soft-flag (shown with warning, patient decides):
- near_expiry — record age > 80% of validity_days
Default-allow for records whose category isn't in the new
procedure's REQUIRES_TEST. Surfaced with procedure_match: "uncertain"
and a note: "may not be needed for this procedure." The patient decides.
Each dropped record emits port.dropped event. Each uncertain record
emits port.surfaced event.
A3. port_records_to_case(db, current_case, selected_record_ids, eligible_records) [Opus]¶
Given the patient's selection from the consent card, port records:
-
FHIR resources: Write a new row in
fhir_resourceswith the sameresource_jsoncontent but a freshid, the newcase_idcolumn value, andsource: "ported". The original resource is NOT modified. -
Documents: Write
case_idof the new case onto a newdocument_referencerow (copy, not move) pointing to the same R2 storage key. The ported document'snotesfield records"Ported from CRW-XXXX". The FHIR resources extracted from that document are already copied in step 1, and theirdocument_idreferences point to the new document copy.
Cross-case document scoping interaction (PR #136): The
ehr_rebuild_service document query (Part D) already includes
documents referenced by FHIR document_id. When we port FHIR
resources that carry a document_id, the EHR rebuild
automatically pulls in the referenced document's
extracted_data for observations. This means ported FHIR
document_id refs auto-pull docs into the new EHR without
needing a separate document-copy step for EHR purposes. However,
we still copy the document row so it appears in the Documents
tab with proper case attribution.
-
Update
case.extra_metadatawith ported data sogates_v2_intake_complete_v2sees medications/allergies. -
Trigger EHR rebuild on the new case.
-
Emit
port.consentevent with{selected_record_ids, ported_count}.
A4. _emit_port_event(db, event_type, case, payload) [Sonnet]¶
Best-effort write to events table via the decision_recorder pattern. Never raises.
PART B — Orchestrator Integration¶
B1. New step in handle_message: porting check after procedure ID [Opus]¶
Insert immediately after _handle_procedure_identification (line ~293
in case_orchestrator.py) and before the records-first branch.
if (
is_feature_enabled("case_record_porting_v1")
and ws.get("procedure_identified")
and not ws.get("port_consent_offered")
):
from app.services.case_porting_service import (
find_portable_records, compute_eligibility,
)
portable = await find_portable_records(db, patient_id, tenant_id, case)
if portable:
eligible, dropped = compute_eligibility(portable, case)
if eligible:
# Build consent card payload grouped by document
consent_payload = _build_consent_payload(eligible)
ws_update = dict(case.workflow_state or {})
ws_update["port_consent_offered"] = True # Audit fix #7
case.workflow_state = ws_update
await db.flush()
return {
"response": (
"I found records from your previous case that are still valid. "
"Take a look and choose which ones you'd like to reuse."
),
"content_type": "port_records_consent",
"rich_content": {
"title": "Reuse prior records",
"records": consent_payload,
"case_id": str(case.id),
},
"agent_used": "case_porting",
"workflow_updates": {"port_consent_offered": True},
"status_update": None,
}
# No portable / no eligible -> mark offered so we don't ask again
ws_update = dict(case.workflow_state or {})
ws_update["port_consent_offered"] = True
case.workflow_state = ws_update
await db.flush()
B2. Consent submission handler [Opus]¶
New orchestrator branch triggered when the patient submits the consent
card (via submitPortConsent caseApi call):
async def _handle_port_consent_submission(
db, case, selected_record_ids: list[str]
):
"""Port selected records, update workflow, return confirmation."""
from app.services.case_porting_service import (
compute_eligibility, find_portable_records, port_records_to_case,
)
# Re-run eligibility (records may have expired since card was shown)
portable = await find_portable_records(db, patient_id, tenant_id, case)
eligible, _ = compute_eligibility(portable, case)
result = await port_records_to_case(
db, case, selected_record_ids, eligible,
)
ws_update = dict(case.workflow_state or {})
ws_update["port_consent_given"] = True # Audit fix #7
case.workflow_state = ws_update
await db.flush()
return {
"response": f"Done — {result.ported_count} records carried over. "
"Let me check if anything else is needed.",
"content_type": "text",
"workflow_updates": {"port_consent_given": True},
}
Wrapped in try/except — on failure, emits port.fallback event and
continues to records-first.
B3. Records-first prompt biasing [Opus]¶
When records-first fires after a successful port, prepend context: "The patient has just confirmed N records ported from their prior case CRW-XXXX. Lead with that confirmation, then ask if there's anything more recent that updates any of the ported records."
Pass via extra_context kwarg through _llm_generate.
PART C — Backend service changes¶
C1. clinical_context.store_resources — no change needed [n/a]¶
The store_resources node already writes case_id via the
FHIRResourceCreate schema (line 477 of clinical_context.py):
No meta.extension approach needed. The column is the source of truth.
C2. risk_assessor.py — honor discontinued medications [Sonnet]¶
# In assess_risks(), medication scan loop
for med in medications:
med_name = med.get("name") if isinstance(med, dict) else med
med_status = med.get("status") if isinstance(med, dict) else None
if med_status == "discontinued":
continue # Skip — doesn't contribute to current risk
Five lines. Backwards-compatible.
PART D — app/services/ehr_rebuild_service.py¶
D1. FHIR query: add case_id filter [Opus]¶
The current FHIR query at lines 77-85 fetches all active resources for
the patient across all cases. Add case_id filter:
fhir_result = await db.execute(
sql_text(
"SELECT resource_type, resource_json, icd_codes, source, "
"document_id, case_id, created_at "
"FROM fhir_resources "
"WHERE patient_id = :pid AND tenant_id = :tid "
"AND status = 'active' "
"AND case_id = :case_id"
),
{"pid": patient_id, "tid": tenant_id, "case_id": str(case.id)},
)
This replaces the patient-wide query. Resources from other cases are
now invisible unless explicitly ported (which creates new rows with the
new case_id).
Note: The existing query already has status = 'active' (line 82),
so audit fix #5 is already satisfied for the EHR rebuild path.
D2. Document query: use case_id column instead of timestamp proxy [Opus]¶
Replace the timestamp proxy at lines 111-124:
doc_result = await db.execute(
sql_text(
"SELECT id, extracted_data, ocr_text, original_filename, uploaded_at "
"FROM document_references "
"WHERE patient_id = :pid AND tenant_id = :tid "
"AND analysis_status = 'completed' "
"AND (case_id = :case_id OR id = ANY(:fhir_doc_ids))"
),
{
"pid": patient_id,
"tid": tenant_id,
"case_id": str(case.id),
"fhir_doc_ids": fhir_doc_ids if fhir_doc_ids else [],
},
)
This replaces uploaded_at >= case_created with case_id = :case_id.
The OR id = ANY(:fhir_doc_ids) clause preserves cross-case document
scoping from PR #136: if a FHIR resource in this case references a
document from another case (via porting), that document's
extracted_data is included in the EHR rebuild.
Interaction with porting (audit fix #8): When port_records_to_case
copies FHIR resources with document_id values, those document_id
refs automatically pull the source documents into the new case's EHR
via this fhir_doc_ids clause. This is the mechanism by which ported
observations appear in the EHR without requiring the patient to
re-upload files.
PART E — Config¶
E1. Feature flag [Sonnet]¶
One flag in config/feature_flags.yaml:
case_record_porting_v1:
description: "Enable record porting consent card between cases"
default: true
The case_record_porting_legacy_check flag from v1 spec is removed.
_check_existing_records() was already deleted — no legacy path to
toggle.
E2. Event types [Sonnet]¶
Three new event types in decision_recorder.py:
| Event | When | Payload |
|---|---|---|
port.consent |
Patient submits consent card | {selected_record_ids, ported_count} |
port.dropped |
Record excluded from consent card | {record_id, reason, record_type} |
port.surfaced |
Uncertain record shown on card | {record_id, category, procedure_match} |
PART F — Frontend (all in curaway-health-navigator/)¶
F1. PortRecordsConsentCard.tsx — UI design [Opus]¶
What the patient sees:
+--------------------------------------------------------------+
| Reuse prior records |
| |
| I found records from your previous case (CRW-2026-0042) |
| that are still valid for this procedure. Choose which |
| ones to carry over: |
| |
| +----------------------------------------------------------+
| | [x] Blood Work Report — CBC, BMP, Lipid Panel |
| | Uploaded Mar 15, 2026 · 18 observations |
| | Source: CRW-2026-0042 |
| +----------------------------------------------------------+
| | [x] Knee X-Ray — Left Knee AP/Lateral |
| | Uploaded Mar 12, 2026 · 4 observations |
| | Source: CRW-2026-0042 |
| +----------------------------------------------------------+
| | [x] Metformin 500mg BID |
| | From medication list · Active |
| | Source: CRW-2026-0042 |
| +----------------------------------------------------------+
| | [x] Hypertension (I10) |
| | Condition · Active |
| | Source: CRW-2026-0042 |
| +----------------------------------------------------------+
| | [ ] Chest X-Ray — PA View [!] |
| | Uploaded Feb 28, 2026 · 2 observations |
| | May not be needed for this procedure |
| +----------------------------------------------------------+
| | [x] CBC from Mar 15 [!] Expires in 5 days |
| | Lab result · Provider may request a new one |
| | Source: CRW-2026-0042 |
| +----------------------------------------------------------+
| |
| [ Use these records ] [ Start fresh ] |
+--------------------------------------------------------------+
Design details:
- Records with
procedure_match: "uncertain"are unchecked by default and have a[!]info icon with tooltip. - Records with
near_expiryflag show an amber "Expires in N days" badge. - All other records are checked by default.
- "Use these records" button is primary (teal). "Start fresh" is secondary (outline).
- Max 7 rows visible; scroll for more.
- Document-type records show observation count and upload date.
- FHIR-type records (medications, conditions, allergies) show status and clinical detail.
Props:
interface PortRecordsConsentCardProps {
caseId: string;
records: Array<{
record_id: string;
record_type: string;
name: string;
detail?: string;
document_name?: string;
observation_count?: number;
uploaded_at?: string;
source_case_number: string;
procedure_match: "required" | "uncertain";
near_expiry?: boolean;
expiry_days_remaining?: number;
}>;
onSubmit: (selectedRecordIds: string[]) => void;
onStartFresh: () => void;
}
What happens after consent:
- Frontend calls
submitPortConsent(caseId, selectedRecordIds). - Backend ports records (Part A3), rebuilds EHR.
- Agent responds with confirmation message + transitions to records-first phase asking about anything new/updated.
- EHR drawer immediately reflects ported data (next drawer open).
F2. ConversationApp.tsx — rich content registration [Sonnet]¶
In the MessageBubble component, add the new content type:
if (contentType === 'port_records_consent') {
return (
<PortRecordsConsentCard
{...rc}
onSubmit={(ids) => handlePortConsent(caseId, ids)}
onStartFresh={() => handleStartFresh(caseId)}
/>
);
}
handlePortConsent POSTs to /api/v1/cases/{caseId}/chat with
extra_metadata: { port_consent: selectedRecordIds }.
handleStartFresh POSTs to /api/v1/cases/{caseId}/chat with
extra_metadata: { port_consent: [] } (empty = start fresh).
F3. EHR drawer behavior after porting [Sonnet]¶
In FullEHRDrawerV2.tsx DocumentsSection (line 424):
-
Documents tab: Ported documents display a teal badge:
"Ported from CRW-XXXX". Implemented by checking the document'snotesfield for the "Ported from" prefix, or by checking ifdocument.case_id !== currentCaseId(cross-case attribution). -
Lab Results tab: Each observation row already shows its source document name (Session 38 fix). Ported observations will naturally show the original document filename — no additional change needed.
-
Conditions tab: Conditions with
source: "ported"show a small "(ported)" label next to the source document name.
F4. caseApi.ts — new method [Sonnet]¶
async submitPortConsent(
caseId: string,
selectedRecordIds: string[]
): Promise<ChatResponse> {
return this.post(`/cases/${caseId}/chat`, {
message: selectedRecordIds.length > 0
? "I'd like to use these records from my previous case."
: "I'd like to start fresh with new records.",
extra_metadata: { port_consent: selectedRecordIds },
});
}
Single endpoint — the orchestrator routes via the port_consent key
in extra_metadata.
PART G — Tests¶
G1. tests/test_case_porting_service.py — 12 unit tests [Sonnet]¶
def test_find_portable_records_returns_empty_when_no_prior_cases()
def test_find_portable_records_returns_only_closed_or_forwarded_cases()
def test_find_portable_records_excludes_current_case()
def test_find_portable_records_excludes_superseded_fhir_resources() # Audit fix #5
def test_find_portable_records_excludes_deleted_documents() # Audit fix #6
def test_compute_eligibility_drops_expired_records()
def test_compute_eligibility_drops_wrong_laterality_imaging()
def test_compute_eligibility_drops_wrong_body_system_imaging()
def test_compute_eligibility_flags_near_expiry_records()
def test_compute_eligibility_default_allows_uncertain_with_marker()
def test_port_records_to_case_writes_new_fhir_with_new_case_id()
def test_port_records_to_case_does_not_modify_source_records()
def test_emit_port_event_never_raises_on_db_failure()
G2. tests/test_case_porting_e2e.py — 2 integration tests [Sonnet]¶
async def test_tkr_to_cabg_ports_general_labs_drops_orthopedic_imaging()
async def test_tkr_left_to_tkr_right_ports_blood_work_drops_left_knee_imaging()
Both use the in-memory SQLite fixture, seed two prior closed cases,
run find_portable_records -> compute_eligibility -> port_records_to_case,
and assert the new case's EHR snapshot contains exactly the expected items.
G3. Existing test impact [Sonnet]¶
tests/test_ehr_rebuild.py needs fixture updates: set case_id on
test FHIR resources to match the test case. ~10 lines of fixture changes.
Edge Cases¶
| Edge Case | Handling |
|---|---|
| Race condition: concurrent porting | port_records_to_case accepts current_case and re-validates case.id before writing. SELECT ... FOR UPDATE on target case row. If case already has ported records, abort with port.fallback. |
Old FHIR resources without case_id |
Pre-migration resources have case_id = NULL. find_portable_records uses case_id.in_(prior_case_ids) which naturally excludes NULLs. Run backfill script to assign case_id to historical resources. |
| Validity window boundary | Strict less-than: age_days < validity_days. A 29-day record with 30-day window passes. A 30-day record does not. Unit test covers boundary. |
| Patient declines all | Empty selected_record_ids skips port_records_to_case, emits port.consent with empty selection, sets port_consent_given: false, falls through to records-first. |
| Record expired between consent display and submission | _handle_port_consent_submission re-runs compute_eligibility. Newly-expired records silently dropped. Emit port.dropped with reason expired_after_consent. |
| Source case deleted during porting | FHIR resources survive case soft-delete. port_records_to_case reads resources by ID. If resources are GDPR-erased, skip with port.dropped reason source_deleted. |
| Duplicate after porting | Before writing each ported resource, check for duplicates in target case by resource_type + content hash. Skip duplicates with port.dropped reason duplicate_in_target. |
| 100+ records | Consent card groups by document/category, max 7 visible rows with scroll. Porting batches writes in groups of 25 with db.flush(). Hard cap of 500 resources. |
| Event emission failure | _emit_port_event swallows exceptions. Ported resources have source: "ported" as secondary audit trail. |
Implementation Checklist¶
A: Backend — Migration + Data Layer¶
- [ ] [Sonnet] A0: Alembic migration adding
case_idtodocument_references - [ ] [Sonnet] A0: Update
DocumentReferencemodel withcase_idcolumn - [ ] [Sonnet] A0: Run backfill SQL to populate
case_idon existing documents - [ ] [Sonnet] A0: Wire
case_idintoconfirm_uploadendpoint - [ ] [Opus] A1:
find_portable_records()— queries FHIR viacase_idcolumn, filtersstatus = 'active', excludes deleted documents - [ ] [Opus] A2:
compute_eligibility()— validity windows, laterality, body system, near-expiry flagging - [ ] [Opus] A3:
port_records_to_case()— copies FHIR rows + document rows with newcase_id, triggers EHR rebuild - [ ] [Sonnet] A4:
_emit_port_event()— best-effort event writer
B: Backend — Service Layer (Orchestrator)¶
- [ ] [Opus] B1: Porting check after procedure ID —
port_consent_offered/port_consent_givenworkflow state - [ ] [Opus] B2:
_handle_port_consent_submissionhandler with re-eligibility check - [ ] [Opus] B3: Records-first prompt biasing after successful port
C: Backend — Existing Service Updates¶
- [ ] [Sonnet] C2:
risk_assessor.pyskipsstatus: discontinuedmedications
D: Backend — EHR Rebuild¶
- [ ] [Opus] D1: FHIR query adds
case_idfilter (replaces patient-wide query) - [ ] [Opus] D2: Document query uses
case_idcolumn instead of timestamp proxy
E: Backend — Config¶
- [ ] [Sonnet] E1:
case_record_porting_v1feature flag - [ ] [Sonnet] E2: Three new event types in
decision_recorder.py
F: Frontend — Rich Card + EHR Drawer¶
- [ ] [Opus] F1:
PortRecordsConsentCard.tsx— per-record checkboxes, uncertain badges, near-expiry warnings - [ ] [Sonnet] F2:
ConversationApp.tsx— registerport_records_consentcontent type - [ ] [Sonnet] F3:
FullEHRDrawerV2.tsx— "Ported from CRW-XXXX" badge on documents, "(ported)" label on conditions - [ ] [Sonnet] F4:
caseApi.ts—submitPortConsent()method
G: Testing¶
- [ ] [Sonnet] G1: 12 unit tests in
test_case_porting_service.py - [ ] [Sonnet] G2: 2 e2e tests in
test_case_porting_e2e.py - [ ] [Sonnet] G3:
test_ehr_rebuild.pyfixture updates forcase_idfilter
Rollout¶
- Spec PR (this one) — spec only, no code.
- Implementation PR — Parts A-G shipped together behind
case_record_porting_v1flag (default true). 2 files new (case_porting_service.py, migration), 5 files modified backend, 2 files new/modified frontend, 14 new tests + fixture updates. - Verification window — 24 hours. Watch
port.consent/port.droppedevents in Langfuse.
References¶
- Steer:
ai-steer/case-record-porting-steer.md - Gap report:
gap-report.md— Gaps #9, #11, #17 gates_v2:ai-steer/conversation-flow-gates-steer.md, PR #70- Cross-case document scoping: PR #136 (EHR rebuild includes docs referenced by FHIR
document_id) - Existing FHIR
case_idcolumn:app/models/fhir_resource.py:75-79 - Existing
store_resourceswritescase_id:app/agents/clinical_context.py:477 - Existing FHIR dedup with superseded status:
app/services/fhir_service.py:37-53 - Current EHR rebuild document scoping:
app/services/ehr_rebuild_service.py:111-124