API Changelog¶
v1.48 — case_id enforcement on FHIR list + MCP tools (#1194 C7, 2026-05-26)¶
BREAKING — public FHIR list endpoint¶
GET /api/v1/patients/{patient_id}/fhir—case_idquery parameter is now REQUIRED. Requests without it return HTTP400with error codeFHIR_CASE_ID_REQUIRED_001. Previously the endpoint returned cross-case FHIR resources for the patient whencase_idwas omitted, which silently leaked unrelated clinical data into external LLM consumers.- Migration: append
?case_id=<case_uuid>to every call. Combine with the existingresource_typeandactive_onlyfilters as before. - Cross-case reads: no longer supported via this public endpoint.
Admin tooling with a legitimate need should call the repository layer
directly (see
docs/runbook/).
BREAKING — MCP tools¶
The Curaway MCP server (app/mcp/server.py) now requires case_id on
two tools that fed clinical data into LLM context windows. Calls without
case_id return a structured TextContent error and execute no query.
- Tool
get_patient_clinical_summary—case_idadded to theinputSchemarequiredarray. Handler returns:"Error executing get_patient_clinical_summary: case_id is required (#1194 C7). …"when missing. - Tool
run_match—case_idadded to theinputSchemarequiredarray. Handler returns:"Error executing run_match: case_id is required (#1194 C7). …"when missing. Match results include the clinical summary in theexplanationfield, so unscoped runs were the same leak surface that PR #1198 closed for the inline matching pipeline.
Why¶
Three enforcement points complete the C7 ring around the C-track work
on issue #1194 (LLM context scoping). Together with E4+E2 (run_match
plumbing, #1207), C6 (case_id-None tripwire, #1202), and C5 (CI scanner
for ungated list_by_patient, #1203), every patient-FHIR read path that
can reach an LLM now either takes a case_id or is loud about not
having one.
New error code¶
| Code | HTTP Status | Description |
|---|---|---|
FHIR_CASE_ID_REQUIRED_001 |
400 | case_id query parameter is required on GET /api/v1/patients/{patient_id}/fhir |
v1.47 — Facilitator Attribution Layer (2026-05-03)¶
Admin facilitator management endpoints¶
-
GET /api/v1/admin/facilitators— List all facilitators (paginated). Response includesid,email,clerk_user_id,is_active,is_deleted,created_at,updated_at. Requiresfacilitator_admin:managepermission. -
POST /api/v1/admin/facilitators— Create a new facilitator record. Required fields:email. Optional fields:phone,notes,name. Returns 409FACILITATOR_DUPLICATE_EMAILif email already in use. Requiresfacilitator_admin:managepermission. -
PATCH /api/v1/admin/facilitators/{facilitator_id}— Update facilitator. Modifiable fields:email,phone,notes,name,is_active. Returns 404FACILITATOR_NOT_FOUNDif facilitator_id does not exist. Returns 409FACILITATOR_DUPLICATE_EMAILif updating email to one in use. Requiresfacilitator_admin:managepermission. -
DELETE /api/v1/admin/facilitators/{facilitator_id}— Soft-delete facilitator. Setsis_deleted=true. Cannot delete if there are attributed records (cases/patients) without?force=trueflag (returns 409FACILITATOR_HAS_ATTRIBUTED_RECORDS). Hard-delete with?force=truerequiresadmin:forcepermission (super_admin only). Requiresfacilitator_admin:managepermission.
Facilitator-scoped case retrieval¶
GET /api/v1/facilitator/sourced-cases— Retrieve cases attributed to the authenticated facilitator. Feature-flag gated (facilitator_sourced_cases_enabled). Returns 403FEATURE_DISABLEDif flag is off for the tenant. Returns cases wherereferred_by_facilitator_idmatches the facilitator's id. Supports pagination.
Patient registration extension¶
POST /api/v1/patients/register— Gained optional fieldreferred_by_facilitator_id(UUID string, nullable). When provided, validates that the facilitator exists and is active; returns 422FACILITATOR_NOT_FOUNDor 422FACILITATOR_INACTIVEon mismatch. This field is automatically propagated to any cases created for the patient. Backwards compatible — existing callers omitting the field continue to work unchanged.
Clerk webhook integration¶
POST /webhooks/clerk— Handler fororganizationMembership.createdevent now attachesfacilitators.clerk_user_idwhen a Clerk user is invited to the facilitators org (tenant-curaway-facilitators). Emits new EventTypeCLERK_ORG_MEMBERSHIP_CREATED_FAC_LINKEDon success. Collisions (email in Clerk but facilitator row not found) emit Telegram alerts (CRITICAL).
Database schema¶
-
New table
facilitators(Alembic migrationdc57d34f821b): stores facilitator profiles with audit trail. Columns:id(UUID),tenant_id,email,phone,notes,name,clerk_user_id,is_active,is_deleted,created_at,updated_at. Soft-delete pattern with RLS. -
New column
Patient.referred_by_facilitator_id(UUID FK, nullable): references the facilitator who referred/enrolled the patient. Populated at registration time ifreferred_by_facilitator_idquery param supplied.ON DELETE SET NULLwhen facilitator is hard-deleted (GDPR Article 17). -
New column
Case.referred_by_facilitator_id(UUID FK, nullable): denormalized fromPatient.referred_by_facilitator_idfor query efficiency. Propagated at case creation time from the patient's referrer. AlsoON DELETE SET NULLon facilitator hard-delete.
New EventType¶
CLERK_ORG_MEMBERSHIP_CREATED_FAC_LINKED: Fired when Clerk webhook successfully links a user to the facilitators table. Payload includesclerk_user_id,facilitator_id, andtenant_id.
No breaking changes¶
- Existing endpoints (
facilitator/delegated-cases,consent/*, admin tenants/users) remain unchanged in shape.
v1.46 — Streaming + WS lifecycle parity for triage path (2026-05-01)¶
Streaming chat — triage path¶
- WS channel
case:{case_id}now streamstokenevents for the triage_agent path, matching the existing behavior of the legacyllm_conversationchat path. Previouslytriage_agent.conversationwas invoke-only, producing a 5-12s silent pause before the full response appeared at once. Patient-facing latency on Path A is now on par with Path B. Fix landed in PR-D (#558).
No new WS event types — token and stream_end already existed.
Triage now emits the same shape from the same channel.
WS lifecycle — findings_incorporated from doc-batch path¶
- WS event
findings_incorporatedis now published from the document-batch handler inapp/services/document_processing.py, in addition to the existing publish fromapp/routers/chat.py:564. Both publish sites guarantee the event fires whenever an assistant message is committed to a case conversation.
Background: the patient app's useWebSocket hook flips
isReviewingBatch=true on batch_complete (showing the "Reviewing
findings" overlay) and =false on findings_incorporated. Without
the doc-batch publish, the overlay stuck for 5 minutes after every
document upload — only the chat router was emitting the companion
event.
Frontend behavior unchanged. Fix landed in PR #559.
No HTTP endpoint changes¶
- No new or modified routes under
/api/v1/. Both changes are refactors (PR-D) and a missing event publish (#559).
v1.45 — Admin tenant settings expansion (2026-04-30)¶
Modified endpoint¶
PATCH /api/v1/admin/tenants/{tenant_id}now accepts four new optional fields that update the tenant'stenant_settingsrow in the same call:data_residency_region— pattern^[a-z][a-z0-9-]{0,49}$(e.g.us-east-1,eu-west-1,ap-south-1).default_locale— BCP-47-ish, pattern^[a-z]{2,3}(-[A-Z]{2})?$(e.g.en,en-US,ar-SA).default_currency— ISO 4217 alpha-3, pattern^[A-Z]{3}$.video_provider— pattern^[a-z][a-z0-9_]{0,30}$(e.g.daily,twilio).
All four are individually optional; the call can mix tenant-level
fields (name, slug, etc.) and settings fields freely. The audit
event's payload.fields_updated lists tenant fields directly and
prefixes settings fields with settings. (e.g.
settings.data_residency_region) so the audit log distinguishes
them.
Response shape now includes a settings block when a settings row
exists (returns null for legacy tenants without one):
{
"tenant_id": "tenant-curaway-ops",
"name": "Curaway Operations",
"slug": "curaway-ops",
"country_code": "USA",
"contact_email": "ops@curaway.ai",
"is_active": true,
"settings": {
"data_residency_region": "us-east-1",
"default_locale": "en",
"default_currency": "USD",
"video_provider": "daily"
}
}
When only tenant fields are in the patch, settings is loaded
but not modified — its current values still come back so callers
see the full state. When any settings field is in the patch
but the tenant has no settings row (legacy migration gap), the
endpoint returns 422 TENANT_INVALID_INPUT rather than silently
dropping the update.
No new error codes — reuses existing TENANT_INVALID_INPUT /
TENANT_NOT_FOUND_001 / TENANT_ALREADY_EXISTS.
Audit payload shape change (downstream consumers — heads up)¶
The payload.fields_updated list on admin.tenant_updated events
now prefixes settings-level fields with settings. (e.g.
settings.data_residency_region). Tenant-level field names are
unchanged. Any external consumer (Metabase dashboard, Langfuse
export, alert grep) that filters on the literal field name will
need to widen its filter to match the new prefix.
Locale regex now accepts script subtags¶
default_locale regex was tightened from ^[a-z]{2,3}(-[A-Z]{2})?$
to ^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$ so zh-Hans,
zh-Hant-TW, sr-Latn-RS etc. validate. Required for the SE Asia
expansion per CLAUDE.md multilingual commitment.
6 service tests + 2 HTTP tests cover: settings + tenant fields combined, pure settings update, no-settings-row → 422, the legacy tenant-only update path that must NOT raise, locale script subtag acceptance, HTTP round-trip with settings response, HTTP 422 on malformed locale. 127 admin tests pass.
v1.44 — Admin audit log endpoint (2026-04-30)¶
New endpoint¶
GET /api/v1/admin/audit-log— paginated, filterable read over the append-onlyeventstable. Backs the audit log viewer on the admin portal. The data is already being written by every admin mutation endpoint (slices 2a/2b + slice 3d + #490); this just exposes it.- Permission:
audit:read(super_admin / platform_admin only — one tier aboveuser:readbecause audit payloads can include cross-tenant patient_ids and case_ids). - Feature flag:
admin_user_management_enabled(paired with the rest of the admin surface). - Filters (all optional, AND semantics):
tenant_id(kebab-case)actor_id(Clerk user_id pattern)event_type(dotted convention, e.g.admin.tenant_created)since(ISO 8601, inclusive lower bound oncreated_at)until(ISO 8601, exclusive upper bound)
- Pagination:
limit(default 50, max 200) +offset. - Response shape:
{ events: [Event], limit, offset, has_more }ordered bycreated_at DESC.has_moreis a peek-ahead boolean rather than a precise total becauseeventsis high-volume andCOUNT(*)would be slow even with indexes. Frontend pagination is offset-based forward iteration. - New error code:
422 AUDIT_INVALID_RANGEwhensince >= until(operator typo, not a 500). - Every Event row includes:
id,event_type,tenant_id,actor_id,patient_id,payload(FlexibleJSON, schema depends on event_type),correlation_id,source_service,created_at(ISO 8601 UTC).
9 HTTP tests cover: ordering, AND filters, actor_id filter, time-range
filter (inclusive since + exclusive until), since >= until 422,
peek-ahead has_more flag, RBAC 403 without audit:read, feature-flag
503, and empty-filter happy path. 121 admin tests pass.
v1.43 — Admin Clerk identity proxy endpoint (2026-04-30)¶
New endpoint¶
GET /api/v1/admin/users/{user_id}/identity— proxies the Clerk Backend API to hydrate the admin Users list withname + email + last_sign_in_at + image_url. The Clerk frontend SDK can't fetch arbitrary users (only the signed-in one), hence the proxy.- Permission:
user:read. Feature flag:admin_user_management_enabled. - Response shape:
{ user_id, found, identity: ClerkIdentityCard | null }pinned via Pydantic so Clerk schema drift can't cascade silently. - Caching:
clerk_identity_cache_ttl_sec(default 90s, dedicated setting). Shorter than the email cache because the cache key isuser_idand a Clerk-side rename is invisible until expiry. - Differentiated 4xx error semantics (defense against transient config issues looking like deletions):
404→ cachedNone, returns{found: false, identity: null}.401/403→ClerkConfigError→ 500SYS_CONFIG_MISSINGenvelope. Not cached.429/5xx→ClerkUnavailableError→ 503CLERK_UNAVAILABLE. Not cached.- Other
4xx→ClerkUnavailableError(schema/protocol issue, surface to caller for retry). - PII-safe error messages. Errors include the
clerk-trace-idresponse header (for support correlation) but not the response body — Clerk error bodies sometimes echo the requested email/user fragment.
No new error codes — reuses SYS_CONFIG_MISSING and CLERK_UNAVAILABLE
from the email lookup endpoint.
v1.42 — Admin roles catalog endpoint (2026-04-29)¶
New endpoint¶
GET /api/v1/admin/roles— returns the canonical platform role catalog (the 8 system roles fromapp/models/role.py::RoleCode). Used by the admin Users + Tenants pages to populate the role-picker dropdown without hardcodingKNOWN_ROLE_CODESon the frontend (which drifts the moment a new role is seeded, e.g.provider_staff).- Permission:
user:read— paired withGET /api/v1/admin/users. - Feature flag:
admin_user_management_enabled(shared with the Users surface; one toggle hides both). - Response shape:
{ roles: [{ id, code, name, description, tenant_type, is_system, permissions? }], total }ordered bycodeASC. Thepermissions[]field is only included for callers carryingrbac:manage(super_admin / platform_admin). A plainuser:readviewer gets the dropdown payload without the platform's permission taxonomy — the field is omitted, not nulled, so a typo on the consumer side fails loudly instead of treating "no permissions" as "empty list". is_systemis alwaystruetoday (all rows are system roles); the field is exposed forward-compatibly for the eventual custom per-tenant roles feature (deferred perapp/models/role.pydocstring).- No pagination — catalog is 8 system roles. If custom per-tenant
roles ever ship, add
tenant_idfilter + limit/offset then. - No new error codes.
403 AUTH_PERMISSION_DENIED(emitted byrequire_permission) and503 FEATURE_DISABLEDreuse the existing taxonomy.
5 HTTP tests cover the happy path, empty-catalog edge case, the RBAC
gate (with explicit error_code assertion), the permissions-strip
contract for non-rbac:manage callers, and the feature-flag 503. 102
admin tests still pass.
v1.41 — Admin cleanup queue (2026-04-29)¶
Backend hygiene fixes¶
X-Result-Truncatedresponse header onGET /api/v1/admin/userswhen the unfiltered total exceedsMAX_SEARCH_RESULTS(10,000). Body also carriestruncated: trueso SPA frontends can warn the user to narrow filters. Closes #477.POST /api/v1/admin/tenantsbody field rename — Python field is nowtenant_id(wasid). Wire-level field staysidvia Pydantic alias; no breaking change for clients. Internal consistency for callers that destructurebody.id(which collided with Python'sidbuiltin).extra='forbid'on every admin Pydantic body model. Stray fields in request bodies now return 422 — defends against silent field-drop on the idempotent comparator (e.g.noteon grant).TenantOrgMappingRepository._flush()now wraps SQL errors as typed domain errors (DuplicateRecordError,StoreUnavailableError) per ADR-0016 — same shape asTenantRepository. Closes #481.- Shared
AdminGuardErrorbase class —LastAdminGuardErrorandActiveCasesGuardErrornow shareapp.services._admin_errors. Future guards (e.g. "deactivate provider with active matches") slot in without duplication. pg_advisory_xact_lockondeactivate_tenant— same pattern asrevoke_role's last-admin guard. Prevents duplicate audit events from concurrent deactivates.UserRoleAdminRepository.list_user_role_pairs_for_tenant— single JOIN replaces N+1 inAdminTenantService.list_members(was:search_user_ids- per-user
list_user_role_pairs). TenantRepository.update_fields→update_non_none_fields— explicit name documents that None means "skip", not "set NULL".
No new endpoints. No new error codes (response shape unchanged on errors). 95 admin tests pass (was 90).
v1.40 — Admin Tenants + org-mapping CRUD (2026-04-29)¶
Backend¶
New endpoints
Tenant CRUD (/api/v1/admin/tenants/*):
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /api/v1/admin/tenants |
tenant:read |
Search by name/slug substring (LIKE-escaped). Filter by country_code, active_only. Paginated. Includes member_count and active_case_count per row. |
| POST | /api/v1/admin/tenants |
tenant:manage |
Create non-provider tenant (idempotent on id). Body: {id, name, slug, contact_email, country_code, type?}. type ∈ {coordinator, mso, admin, facilitator}. Provider tenants must use POST /api/v1/admin/providers/{id}/tenant (existing). Returns 201. |
| GET | /api/v1/admin/tenants/{tenant_id} |
tenant:read |
Detail: name, settings, member_count, active_case_count, org_mappings. |
| PATCH | /api/v1/admin/tenants/{tenant_id} |
tenant:manage |
Update fields. Setting is_active=false is rejected — use DELETE for the active-cases guard. |
| DELETE | /api/v1/admin/tenants/{tenant_id} |
tenant:manage |
Soft-deactivate. force=true bypasses active-cases guard, requires admin:force. |
| GET | /api/v1/admin/tenants/{tenant_id}/members |
tenant:read |
List user_roles rows for the tenant + user_ids (frontend hydrates Clerk identities). |
Org-mapping CRUD (/api/v1/admin/org-mappings/*):
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /api/v1/admin/org-mappings |
tenant:read |
List Clerk org → tenant mappings. Filter by tenant_id, environment, org_role. |
| POST | /api/v1/admin/org-mappings |
org_mapping:manage |
Create mapping. Body: {clerk_org_id, tenant_id, org_role, environment}. Returns 201. |
| DELETE | /api/v1/admin/org-mappings/{id} |
org_mapping:manage |
Hard delete (mapping is config, not patient data). |
New event types (audit log)
admin.tenant_createdadmin.tenant_updatedadmin.tenant_deactivatedadmin.org_mapping_createdadmin.org_mapping_deleted
Plus reuses admin.force_used from slice 2a when force-deactivating a tenant with active cases.
New error codes (see error-codes.md)
TENANT_NOT_FOUND_001(404)TENANT_ALREADY_EXISTS(409)TENANT_INVALID_INPUT(422)TENANT_ACTIVE_CASES_GUARD(409)ORG_MAPPING_NOT_FOUND(404)ORG_MAPPING_DUPLICATE(409)
Pydantic regex validation
tenant_idbody/path:^[a-z][a-z0-9-]{1,35}$slug:^[a-z][a-z0-9-]{1,99}$country_code:^[A-Z]{3}$(ISO 3166-1 alpha-3)clerk_org_id:^org_[A-Za-z0-9]{16,40}$environment:^(production|staging|test)$tenant_type:^(coordinator|mso|admin|facilitator)$
Closes spec gap #476 — AdminTenantService.assert_tenant_exists is the
shared validation hook for callers that need "tenant exists" before writing
rows referencing it (e.g. admin_user_service.grant_role).
Slice 2a followup also landed (carryover from PR #479 review):
- TOCTOU advisory lock now uses two-key
pg_advisory_xact_lock(role, tenant)for 64-bit collision space. - Path user_id validation moved to FastAPI
Path(..., pattern=...)— 422 fires from the standard pipeline before feature-flag check. revoke_rolewith bogus role_id now raises 422RBAC_INVALID_ROLE(was incorrectly 500); true orphan FK on an active row still raises 500RBAC_DATA_INTEGRITY.
See PR #(slice 2b PR).
v1.39 — Admin Users endpoints (2026-04-29)¶
Backend¶
New endpoints (/api/v1/admin/users/*, gated by user:read / user:manage)
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /api/v1/admin/users |
user:read |
Search users across tenants. Filters: q (Clerk user_id substring, ILIKE-escaped), tenant_id, role_code, active_only. Paginated with limit (≤100) + offset. |
| POST | /api/v1/admin/users/by-email/lookup |
user:read |
Body {email}. Resolves Clerk email → user_id. Cached for 5 min (bounded LRU). 503 CLERK_UNAVAILABLE on outage; 500 SYS_CONFIG_MISSING if CLERK_SECRET_KEY unset. |
| GET | /api/v1/admin/users/{user_id} |
user:read |
User detail: roles across tenants + aggregate counts. Frontend hydrates Clerk identity (name, email) client-side. |
| GET | /api/v1/admin/users/{user_id}/roles |
user:read |
All user_roles rows for the user, optional active_only. |
| POST | /api/v1/admin/users/{user_id}/roles |
user:manage |
Body {role_code, tenant_id, note?}. Idempotent grant — insert / reactivate / no-op. note recorded on audit Event. |
| DELETE | /api/v1/admin/users/{user_id}/roles/{role_id} |
user:manage |
Soft revoke. ?force=true bypasses last-admin guard, additionally requires admin:force (super_admin only). PG advisory lock prevents TOCTOU race on the guard. |
New event types (audit log)
admin.user_role_granted— every successful POST to grant a roleadmin.user_role_revoked— every soft revokeadmin.self_mutation— actor == target on grant/revokeadmin.force_used—force=trueused to bypass last-admin guard
New error codes (see error-codes.md)
RBAC_INVALID_ROLE(422) — body role_code unknownRBAC_INVALID_FILTER(400) — query-string or path filter malformedRBAC_LAST_ADMIN_GUARD(409) — revoke would leave 0 active holdersRBAC_FORCE_DENIED(403) —force=truewithoutadmin:forcepermissionRBAC_DATA_INTEGRITY(500) — orphan role_id referenceAUTH_NO_ACTOR(401) — caller identity missing on authenticated routeFEATURE_DISABLED(503) — endpoint disabled by feature flagAUDIT_WRITE_FAILED(500) — DB commit failed; rolled back, retryableCLERK_UNAVAILABLE(503) — Clerk API failure (transient)SYS_CONFIG_MISSING(500) — required env var unset (e.g. CLERK_SECRET_KEY)
New feature flags (see feature-flags.md)
admin_user_management_enabled(defaultfalse) — gates/admin/users/*admin_tenant_management_enabled(defaultfalse) — gates/admin/tenants/*
Both default OFF per spec rollout: identity-override SD via Flagsmith, then 100%.
Pydantic regex validation added to admin Users endpoints:
- Path user_id must match Clerk format ^user_[A-Za-z0-9]{16,40}$
- Body role_code must match ^[a-z][a-z0-9_]{1,63}$
- Body/query tenant_id must match ^[a-z][a-z0-9-]{1,63}$
Feature-flags SDK extension
is_feature_enabled(flag, tenant_id, *, identity=None)—identity(kwarg) overridestenant_idas the Flagsmith identifier so per-user identity overrides work for admin/SD-only rollouts.get_feature_valuemirrors the sameidentityprecedence.
See PR #475 (slice 2a base) and #(slice 2a followup).
v1.38 — Admin portal + Clerk webhook (2026-04-28)¶
Backend¶
Performance
- ICD-10 mapping (medical_extractor) now hits a Redis cache (30-day TTL) keyed on the normalized diagnosis+procedure pair. Top-50 diagnoses cover ~80% of cases; expect ~75% hit rate after warm-up. Toggleable via
icd_cache_enabledflag (default on). See PR #460 / issue #349.
Refactor
- Sweep endpoint (
/api/v1/internal/documents/sweep) and the QStash-driven OCR pipeline (run_ocr_pipeline) now go throughDocumentRepository/ newDocumentAdminRepositoryper ADR-0016. New methods:get_for_internal_ocr,get_for_internal_ocr_no_active_filter,atomic_claim_for_processing,find_stuck_global. No behavior change. See PR #461 / issue #273.
New endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/admin/flags |
List Flagsmith feature definitions + per-environment states |
| GET | /api/v1/admin/flags/{feature_state_id} |
Single flag detail (env value + identity overrides) |
| PATCH | /api/v1/admin/flags/{feature_state_id} |
Update a flag's value (audited via admin.flag_changed) |
| POST | /api/v1/admin/flags/{feature_name}/identities/{identifier} |
Set per-identity override (audited via admin.flag_identity_override) |
| GET | /api/v1/admin/matching/config |
Read active matching weights + engine defaults |
| PATCH | /api/v1/admin/matching/config |
Update matching weights (audited via admin.matching_config_changed) |
| POST | /api/v1/webhooks/clerk |
Clerk → Svix webhook receiver. Today handles organizationMembership.deleted; other event types ack 200 with status=ignored |
All admin endpoints require the feature_flag:manage permission (granted to platform_admin). The Clerk webhook is gated by Svix signature verification only — server-to-server, not RBAC.
New event types (audit log)
admin.flag_changed— every PATCH to a Flagsmith flag's valueadmin.flag_identity_override— per-identity flag override creationadmin.matching_config_changed— matching weights updateclerk.org_membership_deleted— successful org-deletion webhookclerk.webhook_rejected— webhook with invalid Svix signature
New env vars (Railway)
FLAGSMITH_ADMIN_TOKEN— Svix admin scope token (required for/admin/flags/*and/admin/matching/config)FLAGSMITH_PROJECT_ID— numeric project id from Flagsmith dashboardFLAGSMITH_ENVIRONMENT_KEY— server-side env key (ser.…)FLAGSMITH_ADMIN_API_URL— defaults tohttps://api.flagsmith.com/api/v1/CLERK_WEBHOOK_SECRET— Svix signing secret (whsec_…) from Clerk dashboardDEFAULT_TENANT_ID— defaults totenant-apollo-001; lets us flip the platform default tenant without code edits
New required Flagsmith flag
matching_weights_v1— STRING/MULTIVARIATE, JSON-encoded dict with the 5 keysclinical_fit,outcomes,cost,travel,preferences. Sum must be ≈ 1.0 (±0.01). The matching engine reads this at runtime; admin endpoint validates the same contract before write.
Behavior change
WeightedScoringV1.WEIGHTSnow JSON-parses the Flagsmith flag value before applying. Previously the dict-type check on a string never passed and every override was silently ignored. Misconfigured flags now log at WARNING and fall back to engine defaults.
Frontend¶
Admin portal (apps/admin-app/) gets two functional tabs:
- /flags — searchable table with toggle + value-edit, optimistic updates
- /matching — five sliders for matching weights, live total indicator, save gated on sum=100%
Migration¶
No DB migration. Driven entirely by env vars + Flagsmith flag registration.
Refs¶
- PRs: #451, #131 (frontend), #452, #132 (frontend), #455, #456, #457
- Deploy checklist: #454
v1.37 — Facilitators + Coordinators Phase 3 (2026-04-19)¶
Backend¶
New Models
Coordinator— Curaway staff coordinator with tier, language skills, active case countCoordinatorAssignment— links coordinator to case with assignment timestampTransportVendor— transport vendor directory (airport, hotel, hospital)TransportBooking— coordinator transport booking per caseTimelineMilestone— case lifecycle milestones (pre-op, travel, admitted, discharge, follow-up)CoordinatorNote— free-text notes on cases by coordinatorsEscalation— escalation events with type (coordinator_manual,system_overdue), severity, resolutionRating— CSAT ratings for coordinators and facilitators post-caseFacilitatorConsent— patient-granted access delegation to facilitators
New Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/coordinator/assign |
Auto-assign coordinator to case at payment_locked |
| GET | /api/v1/coordinator/queue |
Coordinator case queue with filtering and pagination |
| GET | /api/v1/coordinator/cases/{case_id} |
Full case detail view for coordinator |
| GET | /api/v1/coordinator/vendors |
Transport vendor directory |
| POST | /api/v1/coordinator/bookings |
Create transport booking |
| PATCH | /api/v1/coordinator/bookings/{booking_id} |
Update transport booking |
| GET | /api/v1/coordinator/bookings |
List coordinator's bookings |
| GET | /api/v1/cases/{case_id}/timeline |
Case timeline milestones and events |
| PATCH | /api/v1/cases/{case_id}/timeline/milestone/{name} |
Update milestone status |
| POST | /api/v1/cases/{case_id}/timeline/note |
Add coordinator note to case |
| POST | /api/v1/escalations |
Raise an escalation |
| GET | /api/v1/coordinator/escalations |
Coordinator's open escalations |
| PATCH | /api/v1/escalations/{escalation_id} |
Resolve or update an escalation |
| POST | /api/v1/ratings/submit |
Submit CSAT rating for coordinator or facilitator |
| GET | /api/v1/coordinator/{coordinator_id}/performance |
Coordinator performance metrics |
| POST | /api/v1/consent/facilitator/grant |
Patient grants facilitator access to case |
| POST | /api/v1/consent/facilitator/revoke |
Patient revokes facilitator access |
| GET | /api/v1/consent/facilitator/list |
List active facilitator grants for a case |
| GET | /api/v1/facilitator/delegated-cases |
Facilitator's accessible delegated cases |
Feature Flags
| Flag | Default | Purpose |
|---|---|---|
facilitator_consent_enabled |
false |
Enable facilitator consent grant/revoke flow |
coordinator_assignment_enabled |
false |
Enable auto-assignment at payment_locked |
coordinator_queue_enabled |
false |
Enable coordinator queue dashboard |
coordinator_services_transport_v1 |
false |
Enable transport vendor directory + booking |
coordinator_timeline_enabled |
false |
Enable case timeline milestones and notes |
coordinator_escalation_enabled |
false |
Enable escalation triggers |
coordinator_csat_enabled |
false |
Enable CSAT/ratings collection |
v1.36 — Risk Assessment + MSO Second Opinion Phase 2 (2026-04-19)¶
Backend¶
New Models
RiskReview— risk assessment record with score, blocking factors, reviewer decisionRiskFactor— individual risk factor with source provenance andis_blockingflagMSOMatcher— MSO specialist matching modelMSOConsultation— MSO consultation record (document_review or video)
New Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/risk/queue |
Risk review queue for reviewers (requires risk_reviewer permission) |
| GET | /api/v1/risk/{case_id}/assessment |
Full risk assessment for a case |
| POST | /api/v1/risk/{case_id}/decision |
Reviewer approve/reject/request-info decision |
| GET | /api/v1/risk/{case_id}/history |
Full decision history for a case |
| POST | /api/v1/cases/{case_id}/mso/request |
Patient requests MSO second opinion |
| GET | /api/v1/cases/{case_id}/mso/status |
Current MSO consultation status |
| GET | /api/v1/mso/consultations |
MSO doctor's consultation inbox |
| GET | /api/v1/mso/consultations/{consultation_id} |
MSO consultation detail |
| POST | /api/v1/mso/consultations/{consultation_id}/document |
MSO doctor submits document review |
| POST | /api/v1/mso/consultations/{consultation_id}/records-request |
MSO doctor requests additional records |
Case State Transitions Added
consent_given → risk_review_pending → risk_cleared → providers_notified → mso_offered → mso_complete → payment_locked
Feature Flags
| Flag | Default | Purpose |
|---|---|---|
risk_assessment_enabled |
true |
Gate the entire risk workflow |
risk_review_human_required |
true |
All cases require human review |
risk_blocking_override_allowed |
false |
Allow reviewers to override blocking factors |
mso_consultation_enabled |
false |
Enable MSO matching + consultation flow |
mso_video_embedded |
false |
Use Daily.co embedded video rooms |
v1.35 — Provider Flow Phase 1 (2026-04-19)¶
Backend¶
New Models
ProviderQuote— cost breakdown (line items in USD cents), status lifecycle (draft→submitted→selected/rejected)redacted_snapshotcolumn oncase_shares— stores provider-visible redacted case payload
New Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/provider/cases |
Provider inbox — cases forwarded to this provider's tenant |
| POST | /api/v1/provider/cases |
Accept or action a forwarded case (provider-side) |
| POST | /api/v1/provider/cases/{id}/quote |
Submit a quote for a forwarded case |
| POST | /api/v1/provider/cases/{id}/decline |
Decline a forwarded case |
| GET | /api/v1/cases/{id}/quotes |
Patient-facing quote comparison (gated by provider_portal_v1) |
| POST | /api/v1/admin/providers/{id}/tenant |
Assign a provider to a tenant (admin only) |
| POST | /api/v1/admin/providers/{id}/invite-staff |
Invite provider staff member via Clerk multi-org mock |
New Services
- Case forwarding service — applies
provider_snapshotredaction, createscase_share, triggers provider notification - Provider notification service — events:
case_forwarded,quote_received,provider_selected - Provider onboarding service — Clerk multi-org mock (no live webhook yet; deferred to Phase 2)
Alembic Migration
provider_quotestable: quote line items (JSONB), status, submitted_at, currency (ISO 4217), total_centsredacted_snapshotJSONB column oncase_shares
Feature Flags
| Flag | Default | Purpose |
|---|---|---|
provider_forwarding_v1 |
false |
Enable case forwarding to provider tenants |
provider_clerk_integration |
false |
Enable Clerk multi-org invite flow (mock in Phase 1) |
provider_portal_v1 |
false |
Enable provider portal API + patient quote comparison |
Frontend¶
- Provider portal (
apps/provider-app): case inbox, case detail view, quote builder UI - API wiring with demo mode fallback (used when
provider_portal_v1flag is off) - Design system app (
apps/design-system) — shared component library bootstrapped
v1.34 — Multi-Tenancy Phase 0 (2026-04-18)¶
Backend¶
RBAC
- 7 roles (
patient,facilitator,coordinator,mso_doctor,provider_admin,platform_admin,super_admin), 22 permissions rolesanduser_rolestables with UUID PKs; roles are a global catalog (not tenant-scoped)RBACService—has_permission(),assign_role(),revoke_role(),check_or_raise()RBACMiddleware— resolves user roles from Clerk JWT on every request, setsrequest.state.permissions@require_permission(perm)route decorator for fine-grained endpoint access control
Case Shares
case_sharestable with dual-scope RLS (source_tenant_id OR target_tenant_id = app.tenant_id)SharingMode:copy(provider snapshots) orreference(coordinator / MSO / facilitator)RedactionPolicy:provider_snapshot,mso_clinical,coordinator_full,facilitator_consentCaseShareService— create/revoke shares, consent grant/revoke for facilitators
Redaction Engine
- 4 policies defined in
config/redaction_policies.yaml RedactionEnginesingleton — applies field-level masking (pseudonymize, age-only, budget-range, remove)RedactedCaseDatatyped dataclass prevents unintentional PII leaks
Case Lifecycle State Machine
- 27 states via
python-statemachineinapp/state_machine/case_machine.py CaseLifecycleService— sole writer ofcase.status; every transition is audit logged- Backward-compatible: runs in parallel behind
case_state_machine_v2flag
Modified Endpoints
| Method | Path | Change |
|---|---|---|
| GET | /api/v1/cases/{id} |
Response now includes layer_state when triage_agent_v3 flag is on |
| GET | /api/v1/cases/{id}/scores |
New — returns PFS / HSS / FMS scoring breakdown |
Alembic Migration
rolestable + seed (7 system roles fromconfig/rbac_defaults.yaml)user_rolestable + RLScase_sharestable + dual-scope RLS + composite unique indexes
Feature Flags
| Flag | Default | Purpose |
|---|---|---|
rbac_enforcement |
false |
Enable permission checks on routes |
case_state_machine_v2 |
false |
Use python-statemachine instead of status list |
redaction_engine |
false |
Enable redaction at case share creation |
wave2_layer_ui |
false |
Gate layer_state field in case responses |
v1.33 — SSE Clerk Auth + Conversation Flow Gates v2 + Risk Assessor¶
Date: 2026-04-07
SSE Endpoints Now Require Clerk JWT¶
All three SSE endpoints now require a Clerk JWT supplied via ?token=<jwt> query parameter (since EventSource cannot send Authorization headers). Verified by verify_sse_token in app/services/auth.py.
| Method | Path | Auth |
|---|---|---|
| GET | /api/v1/patients/{id}/documents/stream |
?token=<clerk-jwt> (required) |
| GET | /api/v1/cases/{id}/chat/stream |
?token=<clerk-jwt> (required) |
| GET | /api/v1/cases/{id}/messages/stream |
?token=<clerk-jwt> (required) |
Tenant precedence (hotfix): When both a ?tenant_id= query param and a Clerk org_id claim are present, the caller-supplied query value wins. Patient rows are partitioned under tenant slugs (e.g. tenant-apollo-001), not Clerk org IDs, so the explicit fallback must override.
Forwarding Step: on_site_tests_notice Rich Card¶
The forwarding step now emits an on_site_tests_notice rich card aggregating Neo4j REQUIRES_TEST requirements + per-provider REQUIRES_FOR_PROCEDURE overrides — tells the patient which tests must be repeated on-site at the receiving hospital.
Conversation Flow Gates v2 (gates_v2 flag, default on)¶
_intake_complete_v2 replaces the legacy six-AND gate with a five-condition check (procedure + evidence + age/country + meds-or-skip + allergies-or-skip). completeness_for_matching lowered from 0.5 to 0.4. Patient explicit-advance phrases ("find providers now", "i'm ready", "proceed") promote skip flags and fall through to matching on the same turn.
New events: gates_v2.intake_complete, gates_v2.explicit_advance, gates_v2.matching_advanced.
Pre-Operative Risk Assessor¶
ehr_builder_agent now runs app/services/risk_assessor.py (rule-based, no LLM) on every EHR rebuild. Populates ehr_snapshot.risk_factors with age / comorbidity / medication / lab risks, each carrying source provenance and an is_blocking flag. Blocking risks halt forwarding.
Records-First Real Tests¶
Records-first phase now pulls case_service.get_procedure_requirements() (Neo4j-backed via procedure_reqs_from_graph) into the LLM prompt instead of hallucinating tests. Formatted with validity windows, source acceptance, and on-site flags.
v1.32 — SSE Upload Progress + Inline OCR¶
Date: 2026-04-05
SSE Document Progress Streaming¶
Real-time upload processing progress delivered via Server-Sent Events.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/patients/{id}/documents/stream |
SSE stream for document processing progress (dual-source: Redis 300ms + DB 5s polling) |
Progress Pipeline (6 steps)¶
upload_received -> ocr_started -> ocr_complete -> analysis_started -> analysis_complete -> matching_complete
Each step emits a ProgressEvent with step, label, status, timestamp, detail, and document_id.
New SSE Event Types¶
| Event | Channel | Payload |
|---|---|---|
progress |
doc_progress:{patient_id} |
{step, label, status, timestamp, detail?, document_id} |
heartbeat |
/documents/stream |
{timestamp} |
Inline OCR Fast Path¶
- PyMuPDF runs inline during
confirm_uploadfor native PDFs (>100 chars extracted) - Saves 200-500ms QStash round-trip; falls back to QStash for scanned/image PDFs
- Shared pipeline in
app/services/document_processing.pywithemit_progress()helper
New Schemas¶
ProgressStepenum (6 steps) andProgressEventschema inapp/schemas/document.py
Feature Flag¶
enable_inline_ocr— controls whether inline fast path firesemit_progressduring confirm_upload
v1.31 — Latency Optimization + Test Coverage Sprint¶
Date: 2026-04-04
Performance (13.2s -> 7.9s average, -41%)¶
- Timing middleware: profiles every request segment (auth, state, LLM, DB, cache). Adds
X-Request-Timing-Msheader. - Chat pipeline caching: patient state (60s TTL), conversation history (120s TTL) via Redis cache-aside.
- Parallel LLM calls: input classifier concurrent with context fetch via
asyncio.gather(). - Deferred extraction: chat extractor + EHR updates moved to background tasks after response streams.
- LLM response streaming:
llm.astream()pushes tokens via Redis SSE toGET /cases/{id}/chat/stream. - Connection pooling: LLM clients now singletons. SQLAlchemy
pool_recycle=1800, pool_timeout=30. - Prompt compression: system prompts reduced 36-73% (v2_compressed).
- Inline OCR fast path: PyMuPDF inline during confirm_upload, skips QStash for structured PDFs.
New Endpoints¶
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/cases/{id}/chat/stream |
SSE stream for LLM response tokens (0.1s Redis poll) |
Neo4j Procedure Requirements Wiring¶
graph_service.get_procedure_requirements_for_agents()transforms Neo4j REQUIRES_TEST data- CPT-to-slug mapping (14 procedures) fixes code mismatch
case_service.get_procedure_requirements()routes through Neo4j with PostgreSQL fallback
Test Coverage¶
- 760 backend tests (0 failures, up from ~390 with 31 failures), 32 frontend tests
- 12 new test files (7,772 lines)
New Feature Flags (9 flags)¶
enable_timing_middleware, enable_chat_cache, enable_parallel_pipeline, enable_deferred_extraction, enable_response_streaming, enable_inline_ocr, enable_doc_pipeline_timing, prompt_version, procedure_reqs_from_graph
v1.30 — Procedure Requirements + System Landscape¶
Date: 2026-04-04
Provider Override Management (2 endpoints)¶
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/providers/{slug}/procedures/{code}/requirements/{test_code} |
Create/update provider-specific test requirement override |
| DELETE | /api/v1/providers/{slug}/procedures/{code}/requirements/{test_code} |
Delete provider-specific test requirement override |
System Landscape (renamed from /health)¶
| Method | Path | Description |
|---|---|---|
| GET | /landscape |
System landscape HTML dashboard (renamed from /health) |
| GET | /landscape.json |
System landscape JSON API |
| GET | /health |
301 redirect to /landscape (backward compat) |
Internal Services (no new endpoints)¶
- Chat Extractor: Dedicated Haiku call on every chat turn extracts medications, allergies, demographics from conversation
- LLM Requirement Matcher: Haiku call matches document observations against procedure requirements after clinical extraction
- Agent Tools Card: 18 agent capabilities displayed on landscape page
New Seed Data¶
seed_procedure_tests.py: REQUIRES_TEST for 15 procedures (137 test mappings)- Provider overrides: 7 entries for Apollo Chennai, Bumrungrad, Acibadem
v1.28 — Traceability, Context Engine & Provider Storefront¶
Date: 2026-04-01
Traceability & Feedback (6 endpoints)¶
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/cases/{id}/decisions |
Full decision trace for a case |
| POST | /api/v1/cases/{id}/provider-feedback |
Provider submits EHR corrections |
| POST | /api/v1/cases/{id}/satisfaction |
Patient NPS survey |
| GET | /api/v1/internal/eval/summary |
Eval metrics dashboard |
| GET | /api/v1/internal/feedback/pending |
Unapplied corrections |
| POST | /api/v1/internal/feedback/{id}/applied |
Mark correction applied |
Public Storefront (9 endpoints)¶
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/public/providers |
Provider listing (filterable, paginated) |
| GET | /api/v1/public/providers/{slug} |
Provider detail with facilities, doctors, travel |
| GET | /api/v1/public/doctors |
Doctor listing (filterable) |
| GET | /api/v1/public/doctors/{slug} |
Doctor detail |
| GET | /api/v1/public/treatments |
Treatment categories with provider counts |
| GET | /api/v1/public/treatments/{slug} |
Treatment detail with providers |
| GET | /api/v1/public/destinations |
Country listing with provider counts |
| GET | /api/v1/public/destinations/{slug} |
Country detail with providers |
| GET | /api/v1/public/search |
Unified search (providers, doctors, treatments, destinations) |
Internal Eval (5 endpoints)¶
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/internal/eval/extraction-accuracy |
Nightly extraction eval |
| POST | /api/v1/internal/eval/match-quality |
Weekly match quality eval |
| POST | /api/v1/internal/eval/question-relevance |
Weekly question relevance eval |
| POST | /api/v1/internal/eval/feedback-patterns |
Daily correction pattern detection |
| POST | /api/v1/internal/eval/weight-optimizer |
Monthly weight optimization |
New Tables¶
feedback_records— Decision-outcome links with correction lifecycleprovider_facilities— Provider facility listings (name, description, icon)treatment_categories— Treatment/procedure categories for storefront
New Error Codes¶
- 12 PUBLIC_* codes (PUBLIC_PROVIDER_NOT_FOUND_001 through PUBLIC_SLUG_CONFLICT_012)
New Feature Flags¶
- PUBLIC_API_ENABLED, STOREFRONT_PAGES_ENABLED, PUBLIC_SEARCH_ENABLED, STOREFRONT_CACHE_ENABLED, PROVIDER_COMPLETENESS_DISPLAY
v1.27B — Bug Fixes (Session 27B)¶
Date: 2026-03-31
- First-message attachment processing alongside procedure identification
- EHR deduplication (conditions by ICD-10, observations by parameter name)
- New Case button no longer auto-switches to active case
- Auto case title from procedure_name in ChatResponse
v1.26 — Doctors Schema (Session 26)¶
Date: 2026-03-31
New Endpoints¶
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/doctors/ |
List doctors (paginated, filterable by provider_id, specialty) |
| GET | /api/v1/doctors/search |
Search doctors by specialty, procedure_code, language, gender, min_volume |
| GET | /api/v1/doctors/{doctor_id} |
Get doctor by UUID |
| GET | /api/v1/doctors/slug/{slug} |
Get doctor by URL slug |
| POST | /api/v1/doctors/ |
Create doctor profile (admin) |
| PATCH | /api/v1/doctors/{doctor_id} |
Update doctor profile (admin), recalculates data completeness |
| GET | /api/v1/doctors/{doctor_id}/procedures |
List procedures a doctor performs |
| POST | /api/v1/doctors/{doctor_id}/procedures |
Add procedure to doctor (admin) |
| GET | /api/v1/providers/{provider_id}/doctors |
List doctors at a specific provider |
Modified Responses¶
ScoredProvider(match results) now includes optionaldoctorandlanguage_supportfields whenDOCTORS_IN_MATCHINGfeature flag is enableddoctor: lead doctor profile, procedure stats, language concordance tier, match reasoninglanguage_support: concordance tier, interpreter availability, consent form language
New Error Codes¶
| Code | HTTP | Description |
|---|---|---|
| DOCTOR_NOT_FOUND_001 | 404 | Doctor ID or slug does not exist |
| DOCTOR_VALIDATION_001 | 422 | Required doctor field missing or invalid |
| DOCTOR_DUPLICATE_001 | 409 | Doctor with same name + provider + specialty already exists |
| DOCTOR_PROCEDURE_EXISTS_001 | 409 | Doctor already has this procedure_code assigned |
Feature Flags¶
DOCTORS_IN_MATCHING— When ON, match results include doctor-level data. Default: OFF in production.
v1.25B — Document Matching via Embeddings (Session 25B)¶
- Document checklist now uses embedding-based matching (Qdrant
requirement_embeddings) - Keyword matching retained as fallback
v1.25 — UI/UX Overhaul (Session 25)¶
- No API changes (frontend only)
v1.24 — Agent Guardrails (Session 24)¶
- Input classifier on
/chatendpoint (8 categories) - Output validator on agent responses (12 forbidden patterns)
- File validation at presign time
v1.23 — Document Checklist + Cases (Session 23)¶
/api/v1/cases/{id}/document-checklistendpoint/api/v1/cases/CRUD endpoints- Procedure requirements table and seeding
v1.17 — Core Platform (Session 17)¶
- Initial 17-table schema
- Patient, Provider, FHIR, Consent, Match, Document endpoints
- Matching engine with weighted scoring v1