Skip to content

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:

  1. FHIR resources: Write a new row in fhir_resources with the same resource_json content but a fresh id, the new case_id column value, and source: "ported". The original resource is NOT modified.

  2. Documents: Write case_id of the new case onto a new document_reference row (copy, not move) pointing to the same R2 storage key. The ported document's notes field records "Ported from CRW-XXXX". The FHIR resources extracted from that document are already copied in step 1, and their document_id references 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.

  1. Update case.extra_metadata with ported data so gates_v2 _intake_complete_v2 sees medications/allergies.

  2. Trigger EHR rebuild on the new case.

  3. Emit port.consent event 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()

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):

data = FHIRResourceCreate(
    ...
    case_id=state.get("case_id"),   # Already exists
    ...
)

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_expiry flag 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:

  1. Frontend calls submitPortConsent(caseId, selectedRecordIds).
  2. Backend ports records (Part A3), rebuilds EHR.
  3. Agent responds with confirmation message + transitions to records-first phase asking about anything new/updated.
  4. 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's notes field for the "Ported from" prefix, or by checking if document.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_id to document_references
  • [ ] [Sonnet] A0: Update DocumentReference model with case_id column
  • [ ] [Sonnet] A0: Run backfill SQL to populate case_id on existing documents
  • [ ] [Sonnet] A0: Wire case_id into confirm_upload endpoint
  • [ ] [Opus] A1: find_portable_records() — queries FHIR via case_id column, filters status = '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 new case_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_given workflow state
  • [ ] [Opus] B2: _handle_port_consent_submission handler with re-eligibility check
  • [ ] [Opus] B3: Records-first prompt biasing after successful port

C: Backend — Existing Service Updates

  • [ ] [Sonnet] C2: risk_assessor.py skips status: discontinued medications

D: Backend — EHR Rebuild

  • [ ] [Opus] D1: FHIR query adds case_id filter (replaces patient-wide query)
  • [ ] [Opus] D2: Document query uses case_id column instead of timestamp proxy

E: Backend — Config

  • [ ] [Sonnet] E1: case_record_porting_v1 feature 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 — register port_records_consent content type
  • [ ] [Sonnet] F3: FullEHRDrawerV2.tsx — "Ported from CRW-XXXX" badge on documents, "(ported)" label on conditions
  • [ ] [Sonnet] F4: caseApi.tssubmitPortConsent() 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.py fixture updates for case_id filter

Rollout

  1. Spec PR (this one) — spec only, no code.
  2. Implementation PR — Parts A-G shipped together behind case_record_porting_v1 flag (default true). 2 files new (case_porting_service.py, migration), 5 files modified backend, 2 files new/modified frontend, 14 new tests + fixture updates.
  3. Verification window — 24 hours. Watch port.consent / port.dropped events 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_id column: app/models/fhir_resource.py:75-79
  • Existing store_resources writes case_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