Facilitator Attribution Layer — Feature Spec¶
Status: Draft — pending SD review
Author: Claude (Opus, planning role) — 2026-05-03
Target path: docs/specs/facilitator-attribution-layer-feature.md
Related issue: #615 (D13)
Blocks: #617 (commission ledger / D4), #618 (referral links / D5)
Related epic: #244 (portal completion epic)
Related ADR: ADR-0018 — Multi-Tenancy + 7-Actor Platform Architecture
Companion spec: docs/specs/facilitator-portal-frontend-feature.md §5.4b + §10 D13
Predecessor specs:
- docs/specs/multi-tenancy-phase3-coordinators-facilitators-impl.md (Phase 3 — shipped)
- docs/specs/admin-portal-users-tenants-feature.md (admin tenant CRUD shape — shipped)
1. Goal + Non-Goals¶
Goal¶
Ship the structured backend attribution layer that lets the platform answer "which facilitator generated this patient/case?" — independently of post-hoc delegation (CaseShare). Concretely:
- New
facilitatorstable on tenanttenant-curaway-facilitators(one row per real-world facilitator entity). - New nullable FK
Patient.referred_by_facilitator_id→facilitators.id. - New nullable FK
Case.referred_by_facilitator_id(denormalized at case creation) →facilitators.id. - CRUD admin endpoints to create/list/update facilitators (super_admin only).
- New "sourced cases" endpoint scoped to the JWT-resolved facilitator:
GET /api/v1/facilitator/sourced-cases. - Two new permission codes, granted via Alembic seed.
- Reversible Alembic migration; backfill rule
referred_by_facilitator_id = NULLfor the 41 existing patients (treated as "direct signup"). - Repository + service layer in line with
BaseRepository/BaseUnscopedRepositoryper ADR-0016.
After this work:
- D5 (#618) can wire the referral-link → patient registration cookie loop with somewhere to write the attribution.
- D4 (#617) can build a commission ledger that joins commissions.facilitator_id ←→ facilitators.id ←→ cases.referred_by_facilitator_id.
- The facilitator portal's DelegatedCases page (mislabelled today per portal spec §5.4b) can grow a sibling tab "Sourced cases" (US-1 sister story).
Non-Goals (deferred)¶
- Commission model + ledger — that's #617 / D4 and lives in its own spec. Attribution must land first; commissions are pure consumers of the FK shipped here.
- Referral link infrastructure — #618 / D5; same logic. The cookie reader at signup will use the new
Patient.referred_by_facilitator_idfield shipped here, but the link table + redirect handler are out of scope for this spec. - Facilitator user management UI in the admin portal — slice 2 of admin-portal work. This spec ships the API surface so the UI work is unblocked.
- Multi-user-per-facilitator (e.g. an agency with two staff sharing one facilitator entity) — out of scope; v1 is one Clerk user per facilitator, like coordinators.
- Migrating existing
Patient.referral_sourcefree-text values — kept as informational legacy column. We do not attempt to parse"google_search"strings into facilitator FKs. See §6 / DP-D. - Auto-creating a
CaseSharerow when a facilitator-sourced case is created — sourcing ≠ delegation by design. The facilitator's read access still requires explicit patient consent through the existing/consent/facilitator/grantflow. - Public facilitator profile pages / marketing surfaces — different domain, marketing repo.
- Analytics aggregations (e.g. "top 10 facilitators by case volume") — out of scope; trivial follow-up once the FK exists.
- GDPR Article 17 erasure for facilitators themselves — handled by the existing erasure cascade once the model lands; no special design work required beyond adding
facilitatorsto the cascade plan (covered in §13). - Neo4j attribution edges (e.g.
(:Patient)-[:REFERRED_BY]->(:Facilitator)) — Postgres-only for v1; mirror to graph in a follow-up if matching engine grows facilitator-aware features.
2. Why Now¶
2.1 Sequencing¶
SD's Phase 4 (per docs/specs/multi-tenancy-phase3-coordinators-facilitators-impl.md:48-51) wants Commissions + Referral Links live behind feature flags, then progressively turned on. Audit in portal spec §5.4b proved that neither feature has anywhere to write its data today:
D13 (this spec, #615) → D5 (#618 referral links) → D4 (#617 commissions)
│ │ │
│ └─ writes Patient.refby_fac_id │
│ │
└─────────── adds the column they both need ───────────┘
Without D13, #617's Commission.facilitator_id has nothing to FK to (the roles[code='facilitator'] row + CaseShare.actor_id Clerk user_id is not a database identity — it's a string with no FK and no commission_pct). Without D13, #618's referral-link redirect handler can write a value into a non-existent column.
2.2 Why this is a real gap, not paper architecture¶
Audit findings, fresh 2026-05-03:
$ grep -rli FacilitatorPartnership /Users/srikanthdonthi/Code/Curaway/curaway_src/app /Users/srikanthdonthi/Code/Curaway/curaway_src/alembic
# (no output)
The only thing in the codebase that smells like it: Patient.referral_source: str(100) (free-text) at app/models/patient.py:80. There is no facilitators table. There is no FK from Patient or Case to a structured facilitator record. ADR-0018 line 20 promises "row-level by facilitator_id" — the column doesn't exist.
2.3 Why decoupling from CaseShare matters¶
CaseShare with actor_type='facilitator' is post-hoc delegation (app/models/case_share.py:61-143). It records "the patient granted this facilitator read access to an existing case." That is a fundamentally different relationship from "this case originated because the facilitator referred the patient." Conflating the two is what produced the portal-spec mislabelling: today the DelegatedCases page can answer "which cases am I authorized to see?" but cannot answer "which cases did I generate?" — and commission payouts depend on the latter.
2.4 ADR-0018 inconsistency uncovered during this audit¶
The portal spec and issue #615 both claim ADR-0018 line 253 declares FacilitatorPartnership.commission_pct. Re-reading the ADR (lines 249-256), that block actually defines hospital_recovery_partnerships:
hospital_recovery_partnerships (new)
id, surgical_provider_id, recovery_provider_id, tenant_id,
partnership_type: ENUM(hospital_recommended, curaway_partnered, marketplace),
status: ENUM(active, suspended, expired),
commission_pct: DECIMAL,
...
i.e. the only commission_pct field in ADR-0018 is on a recovery-provider partnership, not a facilitator entity. ADR-0018 line 20 does mention "commission" abstractly for the facilitator actor row but never formalizes a schema. This spec is therefore the first concrete schema for facilitator commission_pct — flagging so reviewers know we are not duplicating an existing design, only extending the implicit one. Capture as a one-line ADR-0018 amendment when this spec ships.
3. Audit of Existing Data (verified 2026-05-03)¶
Mirrors the portal spec §5.4b table format, re-verified against main HEAD (commit 9aa62e7).
3.1 What captures source-attribution-ish data today¶
| Surface | Captures | Doesn't capture | File:line |
|---|---|---|---|
Patient.referral_source: str(100) |
Free-text source string set at registration (e.g. "google_search", "doctor_referral") |
No FK, no schema, no facilitator entity, no validation | app/models/patient.py:80, app/schemas/patient.py:44, app/services/patient_service.py:58, app/seed_demo.py:67 |
CaseShare with actor_type='facilitator' |
Post-hoc delegation — facilitator was granted access AFTER case creation | NOT source attribution. The case existed independently; the share record only proves the facilitator was later granted read access. | app/models/case_share.py:61-143, app/services/facilitator_consent_service.py:211-245 |
roles[code='facilitator'] + CaseShare.actor_id |
Role grant + Clerk user_id (string, no FK) | No facilitators table; no commission_pct; no is_active; no decoupled lifecycle |
app/models/role.py:31-46, alembic/versions/cab789eed1ca_add_phase0_rbac_and_case_shares.py:34-40 |
tenant_org_mappings row for the facilitator Clerk org (NOT YET SEEDED) |
Tenant ↔ Clerk org membership | Per-facilitator identity; no commission attribution | app/models/tenant.py:44-75 |
Patient.external_auth_id (Clerk user_id of the patient) |
Patient identity | Nothing about who referred them | app/models/patient.py:27-29 |
3.2 Patient row count (production)¶
41 patient rows on tenant-curaway-patients per #524 cutover. All of them were created before facilitator infrastructure existed. Default backfill rule Patient.referred_by_facilitator_id = NULL applies to all 41 (DP-D, default).
3.3 What is not in the database today¶
- No
facilitatorstable — confirmed bygrep -rli FacilitatorPartnershipreturning empty. - No
commissionstable —grep -li commission app/returns onlyapp/seed_graph.py(Neo4j relationship label, not application logic). - No
referral_linkstable. - No
Patient.referred_by_facilitator_id, noCase.referred_by_facilitator_id. - No model for
commission_pctanywhere inapp/models/(the onlycommission_pctdeclared in ADR-0018 line 253 is onhospital_recovery_partnerships, see §2.4).
3.4 What patterns exist that we should mirror, not invent¶
- Tenant scoping:
BaseRepository._scoped_query(tenant_id)(app/repositories/base.py:54-62) raisesTenantIsolationViolationon empty tenant_id. - Cross-tenant catalog:
BaseUnscopedRepository(app/repositories/base.py:189-237) — used byRoleRepository(app/repositories/role_repository.py:19-37). - Audit emission:
BaseRepository._audit(...)(app/repositories/base.py:82-103) — see usage inRoleRepository.assign_roleatapp/repositories/role_repository.py:89-117. - Idempotent seed migration:
824b672ec7a4_grant_portal_permissions_to_roles.py:88-152(JSONB||append guarded byNOT (permissions @> '...'), downgrade viapermissions - '...'). - Idempotent table create:
cab789eed1ca_add_phase0_rbac_and_case_shares.py:96-220(CREATE TABLE IF NOT EXISTS,ALTER TABLE … ENABLE ROW LEVEL SECURITY,INSERT … ON CONFLICT DO NOTHING). - Soft-delete column convention:
Patient.is_active+Patient.is_deleted(app/models/patient.py:83-84);BaseRepository._active_only(query)addsWHERE is_deleted=Falseautomatically. - Permission decorator + grants table:
@require_permission(...)(app/middleware/require_permission.py:31-100) +_GRANTStuple in824b672ec7a4_grant_portal_permissions_to_roles.py:88-113. - Admin CRUD route shape:
app/routers/admin_tenants.py:1-80(regex-validated path params,super_admin/platform_adminperms, standardAPIResponseenvelope).
4. Proposed Schema¶
4.1 facilitators table¶
| Column | Type | Nullable | Default | Index | FK / Constraint | Notes |
|---|---|---|---|---|---|---|
id |
VARCHAR(36) |
NOT NULL | — | PK | — | UUIDv4 string. UUIDPrimaryKeyMixin pattern. |
tenant_id |
VARCHAR(36) |
NOT NULL | — | yes (ix_facilitators_tenant_id) |
FK → tenants(id) |
Always tenant-curaway-facilitators. Inherits TenantScopedMixin. |
name |
VARCHAR(200) |
NOT NULL | — | — | — | Display name (e.g. "Aisha Rahman" or agency name). |
email |
VARCHAR(255) |
NOT NULL | — | yes (ux_facilitators_email_lower partial unique on LOWER(email) WHERE is_deleted=false) |
unique-when-active | Used for Clerk invite + admin contact. PII; not encrypted because facilitators are staff-side identities (same convention as coordinators per app/models/coordinator.py). |
phone |
VARCHAR(50) |
NULL | — | — | — | Optional contact. PII; not encrypted (same reason as above). |
clerk_user_id |
VARCHAR(255) |
NULL | — | yes (ux_facilitators_clerk_user_id unique-when-not-null) |
partial unique | DP-A default: nullable so the facilitator entity can pre-exist Clerk signup. Filled in via webhook when facilitator accepts the Clerk invite. |
commission_pct |
NUMERIC(5,4) |
NOT NULL | 0.0000 |
— | CHECK (commission_pct >= 0 AND commission_pct <= 1) |
Stored as fraction (0.1500 = 15%). DP-B default: flat per facilitator. Up to 4 dp precision. |
currency_code |
VARCHAR(3) |
NOT NULL | 'USD' |
— | CHECK (currency_code ~ '^[A-Z]{3}$') |
ISO 4217. Per CLAUDE.md "All money in USD cents" — but the facilitator's currency of payout may differ. Defaults USD; admin can change. |
is_active |
BOOLEAN |
NOT NULL | TRUE |
— | — | Soft-disable. Aligns with Patient.is_active. |
is_deleted |
BOOLEAN |
NOT NULL | FALSE |
— | — | Soft-delete. Same convention as Patient.is_deleted. BaseRepository._active_only filters automatically. |
notes |
TEXT |
NULL | — | — | — | Free-text admin notes (e.g. "Connected by Bhaskar 2026-04"). |
metadata_json |
JSONB (FlexibleJSON) |
NULL | '{}'::jsonb |
— | — | Future expansion (referral channel, region, etc.) without schema churn. |
created_at |
TIMESTAMPTZ |
NOT NULL | NOW() |
— | — | TimestampMixin. |
updated_at |
TIMESTAMPTZ |
NOT NULL | NOW() |
— | — | TimestampMixin. |
4.1.1 Indexes (explicit list)¶
ix_facilitators_tenant_id— every BaseRepository query starts withWHERE tenant_id = :t. Non-unique.ux_facilitators_email_lower— partial unique index,LOWER(email)WHERE is_deleted = FALSE. Prevents two active facilitators sharing an email; allows re-creating after soft-delete.ux_facilitators_clerk_user_id— partial unique index,WHERE clerk_user_id IS NOT NULL. Prevents two facilitator entities mapping to the same Clerk user once they sign up.- (Optional, see §11)
ix_facilitators_active_tenantcovering index on(tenant_id, is_active, is_deleted)if list-active queries dominate.
4.1.2 RLS policy¶
Single-scope (mirrors user_roles, NOT the case_shares dual-scope pattern):
ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON facilitators
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
BaseRepository filtering. Default: enable in this migration (DP-E). The cross-tenant admin lookup uses BaseUnscopedRepository.get_by_id_any_tenant, which is RLS-aware via current_setting('app.tenant_id', true) set per-request, so RLS does not block it.
4.2 Patient.referred_by_facilitator_id (new column)¶
| Column | Type | Nullable | Default | Index | FK | Notes |
|---|---|---|---|---|---|---|
referred_by_facilitator_id |
VARCHAR(36) |
YES | NULL |
yes (ix_patients_referred_by_facilitator_id) |
ON DELETE SET NULL → facilitators(id) |
Set at registration via cookie (D5/#618) or admin override. Backfill rule: NULL for all 41 existing rows (DP-D default). Cross-tenant FK — patient lives on tenant-curaway-patients, facilitator lives on tenant-curaway-facilitators. Same cross-tenant FK pattern as case_shares.source_tenant_id / target_tenant_id (app/models/case_share.py:75-80). |
4.3 Case.referred_by_facilitator_id (denormalization)¶
| Column | Type | Nullable | Default | Index | FK | Notes |
|---|---|---|---|---|---|---|
referred_by_facilitator_id |
VARCHAR(36) |
YES | NULL |
yes (ix_cases_referred_by_facilitator_id) |
ON DELETE SET NULL → facilitators(id) |
Default: ON. Set at case creation by reading Patient.referred_by_facilitator_id; never recomputed (DP-C immutability). |
4.3.1 Justification: ON¶
- Commission queries are case-centric, not patient-centric.
SELECT … FROM cases WHERE referred_by_facilitator_id = :f AND status = 'forwarded'is the canonical commission accrual query in #617. JOIN throughPatientwould require either (a) a cross-tenant join (Patient ontenant-curaway-patients, Case on the patient's tenant) or (b) the matching ORM session to set both tenants — both fragile. - Immutability matches DP-C semantics: even if
Patient.referred_by_facilitator_idis later corrected by admin, the case's attribution stays frozen at creation time.Case.referred_by_facilitator_idis the audit trail. - Cost is low: one extra column per case row (UUID = 36 bytes). No write-amplification because cases are created once.
- Read perf: index lookup is O(log n); JOIN-through-Patient is O(n) without an index pair on both sides.
4.3.2 Alternative considered: OFF¶
- Pros: less denormalization; one fewer column to maintain.
- Cons: every commission query is a JOIN; immutability requires a separate
commission_attribution_audittable (more complex). Cross-tenant JOIN risk. - Rejected.
4.4 Audit + soft-delete strategy¶
- Every create/update/delete on
facilitatorsemits anAuditLogrow via_audit(...)(mirrorsRoleRepository.assign_roleatapp/repositories/role_repository.py:89-117). - Soft-delete (
is_deleted=true) preserves history for commissions: a deleted facilitator's past commissions remain queryable. - Hard-delete reserved for GDPR Article 17 erasure (
docs/runbook/erasure.mdcascade — to be amended by this PR per §13).
5. API Surface¶
All under /api/v1/. Standard APIResponse envelope per app/schemas/response.py. All requests require X-Tenant-ID + Clerk JWT. X-Correlation-ID auto-generated.
5.1 Admin endpoints (super_admin only, until DP-G expands)¶
GET /api/v1/admin/facilitators¶
- Permission:
facilitator_admin:manage(new perm — see §6). - Query params:
is_active: bool | None(filter — default unfiltered)q: str | None(case-insensitivename/emailsubstring; minimum 2 chars)page: int = 1,page_size: int = 20(max 100)- Tenant context:
X-Tenant-IDmust betenant-curaway-facilitatorsOR caller hastenant:override(super_admin) for cross-tenant inspection. - Response shape:
APIResponse[list[FacilitatorRead]](paginated wrapper or list — matchadmin_users.pypattern, TBD in implementation). - Returns: facilitators ordered by
created_at DESC. Soft-deleted excluded by default.
POST /api/v1/admin/facilitators¶
- Permission:
facilitator_admin:manage. - Body (
FacilitatorCreate):name(1-200),email(RFC 5322),phone(optional, ≤50),commission_pct(0..1, ≤4dp),currency_code(ISO 4217 alpha-3),notes(optional),metadata_json(optional).clerk_user_idis not accepted at create (DP-A default — populated later by webhook). - Tenant context: request
X-Tenant-IDmust equaltenant-curaway-facilitators. - Idempotency: standard
X-Idempotency-Keyper CLAUDE.md "API Conventions".emailis the natural dedup key — duplicate active email → 409 witherror_code=FACILITATOR_DUPLICATE_EMAIL. - Response:
APIResponse[FacilitatorRead], 201 Created.
PATCH /api/v1/admin/facilitators/{facilitator_id}¶
- Permission:
facilitator_admin:manage. - Path param regex:
^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$(UUIDv4). - Body (
FacilitatorUpdate): any subset of:name,email,phone,commission_pct,currency_code,is_active,notes,metadata_json. Cannot mutatetenant_id,id,clerk_user_id(the last is set by Clerk webhook only). - Behavior on
commission_pctchange: new value applies to new cases only. HistoricalCase.referred_by_facilitator_idrows + future commissions on them remain attributed to the same facilitator, but the commission_pct used for any historical commission is whatever was on the facilitator row at the time the commission was accrued (D4/#617 freezes it on the commission row, not here). DP-C consistency. - Audit:
facilitator.updatewith{"before": {...}, "after": {...}}payload. - Response:
APIResponse[FacilitatorRead].
DELETE /api/v1/admin/facilitators/{facilitator_id}¶
- Permission:
facilitator_admin:manage(delete) ANDadmin:force(super_admin only) if facilitator has any cases attributed. - Behavior: soft-delete (
is_active=false,is_deleted=true). Hard-delete reserved for GDPR cascade. - Guard: if
Patient.referred_by_facilitator_idorCase.referred_by_facilitator_idreferences this facilitator andadmin:forceis missing → 409FACILITATOR_HAS_ATTRIBUTED_RECORDS. Mirrors the active-cases guard on tenant deactivate (app/services/admin_tenant_service.py).
5.2 Facilitator-facing endpoint (the JWT user themselves)¶
GET /api/v1/facilitator/sourced-cases¶
- Permission:
facilitator:sourced-cases:read(new perm — granted tofacilitatorrole). - Tenant context:
X-Tenant-ID = tenant-curaway-facilitators. - Resolution path:
request.state.user_id→facilitatorsrow WHEREclerk_user_id = :user_idANDis_deleted=falseANDis_active=true→facilitator_id→ querycases WHERE referred_by_facilitator_id = :facilitator_id. - If no facilitator row matches: return
APIResponse[[]](empty), NOT 404. The user is authenticated but not a known facilitator entity; treat as "no sourced cases yet." - Cross-tenant query:
casestable is on the patient's tenant (tenant-curaway-patients), facilitator is ontenant-curaway-facilitators. UseBaseUnscopedRepositoryfor the cases query — same patternmatch_service.get_doctors_for_procedureuses post-#604. Filter strictly byreferred_by_facilitator_id. Thetarget_tenant_idfilter that protectsdelegated-casesdoesn't apply here (sourcing is not aCaseSharerelationship). - Response shape:
APIResponse[list[SourcedCaseItem]]: - What it deliberately does NOT return: patient name, patient email, clinical detail, anything PHI/PII. Sourcing is a billing/attribution concern, not a clinical-access concern. The facilitator gets clinical access only through
CaseShare(the existingdelegated-casesflow). - Pagination:
page,page_size— default 20, max 100. Default ordering:Case.created_at DESC. - Why separate from
delegated-cases: semantics differ — sourced ≠ delegated. A facilitator may have sourced 50 cases but only 20 of them have an active consentCaseShare; both numbers must be independently visible.
5.3 Webhook (Clerk → backend) — populates clerk_user_id (DP-A consequence)¶
- Endpoint: existing Clerk webhook (
app/routers/webhooks_clerk.pyif extant, or grow this work into a separate handler — verify in implementation). - Trigger: Clerk
organizationMembership.createdfor the facilitator org. - Action: lookup
facilitatorsrow by email (case-insensitive); if found andclerk_user_id IS NULL, set it. If the email isn't found or the row already has a differentclerk_user_id→ logWARNING+ Telegram alert perapp/services/alerting.py. Do NOT silently overwrite.
5.4 Patient registration — accepts optional referred_by_facilitator_id¶
- Endpoint: existing
POST /api/v1/patients/register(app/routers/patients.py:58-). - Body (
PatientRegisterextension): add optionalreferred_by_facilitator_id: str | None(UUID validation when present). - Behavior: if present, validate via
BaseUnscopedRepository.get_by_idagainstfacilitators(cross-tenant read; facilitator must beis_active=true AND is_deleted=false); else 422 witherror_code=FACILITATOR_NOT_FOUND. On success, persist intoPatient.referred_by_facilitator_id. - Permission: unchanged — patient self-registration. Tenant:
tenant-curaway-patients. - No backwards-incompatible change: the field is optional, defaults to NULL. Existing patient-app code that doesn't send the field continues to work.
5.5 Case creation — propagates to Case.referred_by_facilitator_id¶
- Where:
app/services/patient_service.py(or whereverCaserows are first inserted —app/services/case_service.pyif applicable; verify in impl). - Behavior: at case creation, copy
Patient.referred_by_facilitator_idintoCase.referred_by_facilitator_id. Frozen thereafter — DP-C. - Edge case: if patient has NULL
referred_by_facilitator_idat case-creation time but admin later attaches one, new cases pick up the new value; existing cases keep their original NULL. This is the DP-C immutability default.
5.6 Permissions matrix¶
Two new permission codes, two grant rows in the seed migration (mirrors 824b672ec7a4_grant_portal_permissions_to_roles.py).
| Permission | Granted to (role codes) | Why |
|---|---|---|
facilitator_admin:manage |
super_admin, platform_admin |
Manage facilitator records (CRUD). Same scope as tenant:manage / user:manage. |
facilitator:sourced-cases:read |
facilitator |
List own sourced cases. Mirrors case:read:delegated pattern. |
| Role | facilitator_admin:manage |
facilitator:sourced-cases:read |
|---|---|---|
patient |
— | — |
facilitator |
— | ✅ |
coordinator |
— | — |
mso_doctor |
— | — |
provider_admin |
— | — |
provider_staff |
— | — |
platform_admin |
✅ | — |
super_admin |
✅ | (implicit via tenant:override — but explicit grant simplifies test scanner) |
Update both config/rbac_defaults.yaml (the canonical YAML at lines 17-77) AND emit a JSONB seed via Alembic per the _GRANTS tuple pattern at 824b672ec7a4_grant_portal_permissions_to_roles.py:88-113.
6. Service + Repository Layout¶
Follow ADR-0016 dependency direction: Models → Repositories → Services → Routers. Repos flush, never commit.
6.1 Repositories (new files)¶
app/repositories/facilitator_repository.py¶
- Class
FacilitatorRepository(BaseRepository),model = Facilitator. - Tenant-scoped (every query starts from
_scoped_query(tenant_id)). - Methods:
get_by_id(facilitator_id, tenant_id) -> Facilitator | Noneget_by_email(email_lower, tenant_id) -> Facilitator | None— case-insensitiveget_by_clerk_user_id(clerk_user_id, tenant_id) -> Facilitator | Nonelist_active(tenant_id, q=None, is_active=True, page=1, page_size=20) -> tuple[list, int]create(facilitator, tenant_id, actor_id) -> Facilitator— emitsfacilitator.createauditupdate(facilitator, tenant_id, actor_id, changes) -> Facilitator— emitsfacilitator.updatewith before/aftersoft_delete(facilitator_id, tenant_id, actor_id) -> bool— emitsfacilitator.soft_deleteattach_clerk_user_id(facilitator_id, clerk_user_id, tenant_id, actor_id="webhook:clerk") -> Facilitator— emitsfacilitator.clerk_link. Rejects overwrite of an already-setclerk_user_id.- Audit pattern mirrors
RoleRepository.assign_roleatapp/repositories/role_repository.py:89-117.
app/repositories/facilitator_unscoped_repository.py (or fold into the file above)¶
- Class
FacilitatorUnscopedRepository(BaseUnscopedRepository),model = Facilitator. - For the cross-tenant lookup at patient registration:
get_by_id_any_tenant(facilitator_id) -> Facilitator | None. MirrorsProviderRepository. Active+not-deleted filter applied. - For Clerk webhook:
get_by_email_any_tenant(email_lower). - Logged in
audit_logwithactor_type='system'per the BaseUnscopedRepository docstring (app/repositories/base.py:189-198).
6.2 Services (new files)¶
app/services/facilitator_service.py¶
- Pure orchestration over the repository. No DB queries directly.
- Functions:
create_facilitator(db, data, actor_id) -> Facilitator— validates uniqueness, persists, audits.get_facilitator(db, facilitator_id, tenant_id) -> Facilitator | Nonelist_facilitators(db, tenant_id, q, is_active, page, page_size)update_facilitator(db, facilitator_id, data, tenant_id, actor_id) -> Facilitatorsoft_delete_facilitator(db, facilitator_id, tenant_id, actor_id, force=False)— applies the active-cases guard.attach_clerk_user_id_via_webhook(db, email_lower, clerk_user_id) -> Facilitator | Noneget_sourced_cases(db, facilitator_id, page, page_size)— usesBaseUnscopedRepositoryfor thecasesquery (cross-tenant read).- Errors:
FacilitatorNotFoundError,FacilitatorDuplicateEmailError,FacilitatorHasAttributedRecordsError— raised, mapped to 4xx in router.
6.3 Schemas (new file)¶
app/schemas/facilitator_admin.py¶
FacilitatorCreate(BaseModel)— see §5.1.FacilitatorUpdate(BaseModel)— partial update, all fields Optional.FacilitatorRead(BaseModel)— full read shape (excludesis_deletedfrom the wire).SourcedCaseItem(BaseModel)— see §5.2.- Distinct file from existing
app/schemas/facilitator.py(which holds the consent schemas) — no cross-import. Keeps the domain boundary clean per CLAUDE.md "Domain Boundaries."
6.4 Routers (new file)¶
app/routers/admin_facilitators.py¶
- Mirrors
app/routers/admin_tenants.py:1-80shape: regex path validation,super_admin/platform_adminperms via@require_permission,APIResponseenvelope, structured error mapping. - Routes:
GET /api/v1/admin/facilitators,POST …,PATCH /admin/facilitators/{id},DELETE /admin/facilitators/{id}.
Extend app/routers/facilitator_portal.py¶
- Add
GET /api/v1/facilitator/sourced-casesroute, gated byfacilitator:sourced-cases:read. - Reuses
_check_facilitator_enabled(tenant_id)if SD wants the same FF gate; else gate behind a newfacilitator_sourced_cases_enabledflag (DP-F, default OFF until §11 §11.4 cutover).
6.5 Model file¶
app/models/facilitator.py (new)¶
- Class
Facilitator(Base, UUIDPrimaryKeyMixin, TenantScopedMixin, TimestampMixin). - All columns per §4.1.
__repr__returns<Facilitator id={...} email={...}>. Do NOT log raw email (per CLAUDE.md "No PII in logs"). Truncate or mask in logging.
6.6 No cross-domain imports¶
Per CLAUDE.md "Domain Boundaries": facilitator_service.get_sourced_cases reads cases via repository injection — not by importing case_service. The cases table is read with BaseUnscopedRepository because the read crosses tenants.
7. Alembic Migration Shape¶
7.1 Single migration vs multi-step¶
Recommendation: TWO migrations, sequenced. Single migration would be reversible but couples three otherwise-independent concerns; splitting matches the per-PR review surface and lowers downgrade risk.
Migration A (this spec, one PR): <rev>_add_facilitators_table_and_attribution_fks.py
1. CREATE TABLE IF NOT EXISTS facilitators (…) per §4.1.
2. Indexes (3 mandatory + 1 optional).
3. Insert one tenants row for tenant-curaway-facilitators (idempotent ON CONFLICT (id) DO NOTHING) — required so the tenant_id FK on facilitators can be satisfied. Mirrors 5d49162011a5_add_tenant_curaway_patients_row.py.
4. ALTER TABLE patients ADD COLUMN referred_by_facilitator_id VARCHAR(36) NULL REFERENCES facilitators(id) ON DELETE SET NULL; + index.
5. ALTER TABLE cases ADD COLUMN referred_by_facilitator_id VARCHAR(36) NULL REFERENCES facilitators(id) ON DELETE SET NULL; + index.
6. ALTER TABLE facilitators ENABLE ROW LEVEL SECURITY; + CREATE POLICY tenant_isolation … (single-scope).
7. GRANT SELECT, INSERT, UPDATE, DELETE ON facilitators TO curaway_app; (DO $$ guarded — see cab789eed1ca_add_phase0_rbac_and_case_shares.py:210-220).
8. Permission grant: _add_perm('facilitator', 'facilitator:sourced-cases:read'), _add_perm('platform_admin', 'facilitator_admin:manage'), _add_perm('super_admin', 'facilitator_admin:manage'). Same JSONB-append pattern as 824b672ec7a4_grant_portal_permissions_to_roles.py:116-127.
down_revision: most recent head (currently c8d3f2a91b4e per alembic/versions/c8d3f2a91b4e_enable_rls_on_llm_usage.py). Verify at PR time.
Migration B (D5/#618 spec): seeds the actual referral-link cookie reader. Out of scope here.
Migration C (D4/#617 spec): commissions table referencing facilitators.id. Out of scope here.
7.2 Reversibility¶
downgrade() reverses in order:
1. _remove_perm for both new permission rows.
2. Revoke curaway_app privileges.
3. DROP POLICY IF EXISTS tenant_isolation ON facilitators; + ALTER TABLE … DISABLE ROW LEVEL SECURITY;.
4. ALTER TABLE cases DROP COLUMN IF EXISTS referred_by_facilitator_id;
5. ALTER TABLE patients DROP COLUMN IF EXISTS referred_by_facilitator_id;
6. DROP TABLE IF EXISTS facilitators;
7. DELETE FROM tenants WHERE id = 'tenant-curaway-facilitators'; (only if no rows on that tenant).
Mirrors cab789eed1ca_add_phase0_rbac_and_case_shares.py:248-273 shape.
7.3 Migration safety¶
- Adding a NULLable column to
patientsandcasesis safe online (no rewrite, no lock escalation on Postgres ≥11). Verified pattern:e8f9a0b1c2d3_add_channel_preference_to_patients.py. - Adding the FK with
NOT VALIDthenVALIDATE CONSTRAINTis the zero-downtime variant if either table grows large; not required at 41 patients. tenant-curaway-facilitatorsrow insert must run BEFOREfacilitatorstable is created (Postgres FK checks fire at row-insert time when the constraint is non-deferred). Order strictly: tenants row → facilitators table → FK columns on patients/cases. All DDL + seeds run inside the single Alembic upgrade transaction — noop.execute("COMMIT")calls scattered through the migration; if any statement fails, the entire upgrade rolls back atomically.
8. Backfill Strategy¶
8.1 Default (DP-D recommended)¶
All 41 existing patient rows: referred_by_facilitator_id = NULL ("direct signup").
Rationale:
- They signed up before facilitator infra existed — claiming any of them was facilitator-sourced is fabrication.
- NULL is the schema default; no SQL needed beyond the DDL.
- Aligns with how Phase 0 RBAC handled the legacy "no role assigned yet" state — leave NULL, let the new feature land cleanly.
- Forward-compatible: admin can manually set referred_by_facilitator_id per patient via PATCH /api/v1/admin/patients/{id} (existing endpoint — verify) once facilitator records exist, with audit trail. No backfill SQL needed.
8.2 Alternative considered¶
Admin-driven retroactive tagging via a new POST /api/v1/admin/facilitators/{id}/attribute-existing-patients endpoint that takes a list of patient_ids. Useful only if SD has out-of-band knowledge that specific facilitators sent specific pre-attribution patients. Not recommended for v1; ship the simpler default and grow this only on demand.
8.3 Existing Patient.referral_source (free-text) — what happens?¶
Nothing. Leave as-is. It is informational legacy data; the column stays in the schema. Do NOT attempt to parse "google_search" / "doctor_referral" strings into facilitator FKs — no facilitator entity exists to map them to, and we do not want a brittle string-matching layer. The two columns coexist:
referral_source: str | None— free-text marketing source ("google", "doctor_referral", "instagram_ad").referred_by_facilitator_id: str | None FK— structured facilitator entity (this spec).
Both are nullable. Both can be set independently. A patient can have both (source="instagram_ad", referred_by="<facilitator-id>") — meaning "they came through an Instagram ad placed by this facilitator."
8.4 Cases backfill¶
- All 41 patients' existing cases:
Case.referred_by_facilitator_id = NULLper the DP-D rule (NULL on patient → NULL on case). - No SQL needed; the column defaults NULL.
8.5 Validation post-migration¶
-- Should be exactly 0 attributed patients:
SELECT COUNT(*) FROM patients WHERE referred_by_facilitator_id IS NOT NULL;
-- Should be exactly 0 attributed cases:
SELECT COUNT(*) FROM cases WHERE referred_by_facilitator_id IS NOT NULL;
-- Should be exactly 0 facilitator rows (until first POST /admin/facilitators):
SELECT COUNT(*) FROM facilitators;
9. Decision Points¶
9.1 SD's 4 explicit DPs¶
| ID | Decision | Default proposed | Tradeoff |
|---|---|---|---|
| DP-A | Coupling to Clerk identity. Is the facilitators row decoupled from the Clerk user (nullable clerk_user_id) or always 1:1? |
Decoupled. Nullable clerk_user_id. Admin creates the entity; Clerk webhook fills the column when the facilitator accepts the invite. |
Pro: admin can pre-stage facilitators, attach commission_pct ahead of signup, run reports before they sign in. Pro: mirrors how patients were originally seeded (pre-Clerk demo data). Con: requires the Clerk webhook handler (§5.3). Con: introduces a "ghost facilitator" state — handled by is_active=true AND clerk_user_id IS NULL filter on the sourced-cases endpoint (no rows returned). |
| DP-B | commission_pct semantics. Flat per-facilitator vs per-procedure-class vs per-tenant vs per-patient. |
Flat per-facilitator. Simplest, matches ADR-0018's implicit promise (line 20). | Pro: ships in a week, not a month. Pro: aligns with how hospital_recovery_partnerships.commission_pct works (single DECIMAL, ADR-0018:253). Con: if SD later wants per-procedure (e.g. surgery 10%, consultation 5%), we add a commission_overrides table — additive, not breaking. Pro: the eventual #617 commission ledger freezes commission_pct on each commission row at accrual time, so historical fairness isn't compromised by a future schema bump here. |
| DP-C | Immutability of historical attribution. When admin changes Patient.referred_by_facilitator_id, what happens to existing commissions? |
Freeze existing commissions on the original facilitator. New cases attach to new facilitator. Audit log captures the change. | Pro: zero risk of re-assigning paid commissions. Pro: matches how Case.case_number is immutable, payments are immutable, etc. Pro: the denormalized Case.referred_by_facilitator_id (§4.3) is the audit trail — never recomputed. Con: if admin made a correction (genuine wrong attribution), they must hand-reverse via #617's reversal flow — acceptable, low frequency. Rejected alternative: recompute all attributions — risky, could re-assign paid commissions. |
| DP-D | Backfill rule for existing 41 patients. | All NULL = "direct signup." | Pro: simplest. Pro: aligns with truth — facilitator infra didn't exist when they registered. Pro: zero SQL beyond DDL. Con: if SD knows of off-platform facilitators who DID source pre-Phase-4 patients, they must tag retroactively via per-patient PATCH (acceptable hand-tagging at 41 rows; would not scale at 4100). Rejected alternative: admin retroactive tag endpoint — only worth it if SD has the list. |
9.2 Additional DPs surfaced during this audit¶
| ID | Decision | Default proposed | Tradeoff |
|---|---|---|---|
| DP-E | RLS on facilitators table (single-scope policy in §4.1.2). |
Yes, enable RLS in the same migration. Defense-in-depth on top of BaseRepository filtering. |
Pro: consistent with user_roles (cab789eed1ca_…:189-194) and llm_usage (c8d3f2a91b4e_…). Pro: super_admin cross-tenant reads still work via tenant:override + current_setting('app.tenant_id', true) set per-request. Con: adds one more table to the RLS roster — auditable but small cost. |
| DP-F | Feature flag for the new GET /facilitator/sourced-cases endpoint. |
Add facilitator_sourced_cases_enabled to config/feature_flags.yaml, default OFF. Sync to Flagsmith via scripts/sync_flagsmith.py --dry-run. |
Pro: matches CLAUDE.md "Feature Flags Everywhere" ground rule. Pro: the frontend portal already gates pages behind FFs — symmetric. Con: one more flag to maintain. Decision: default OFF lets the migration land + admin endpoints be tested, then flip per-tenant on cutover. |
| DP-G | Should the facilitator role itself get facilitator_admin:manage (i.e. self-service editing of own commission_pct, name, etc.)? |
No, super_admin/platform_admin only. v1 ships read-only self-view via the role's existing perms; admin-edits go through Curaway staff. | Pro: keeps the commission-edit surface tight (compliance/finance concern). Pro: consistent with how coordinator role does NOT manage tenant-level config. Con: every name/phone update is a support ticket — accept that at v1 volume. Future: add facilitator:profile:write:own perm for self-service of name, phone, notes in a future spec. |
| DP-H | Cross-tenant FK from Patient.referred_by_facilitator_id → facilitators.id (patient on tenant-curaway-patients, facilitator on tenant-curaway-facilitators). |
Yes, plain Postgres FK. Postgres FKs are tenant-agnostic at the DB layer; tenant scoping is application-layer (BaseRepository) + RLS. |
Pro: existing case_shares.source_tenant_id/target_tenant_id proves cross-tenant FKs already work (app/models/case_share.py:75-80). Pro: ON DELETE SET NULL handles facilitator hard-delete safely. Con: RLS on facilitators could block a patient-tenant query that joins to facilitators — use BaseUnscopedRepository.get_by_id_any_tenant for the validation read (§6.1). |
| DP-I | Naming: referred_by_facilitator_id vs source_facilitator_id vs attributed_facilitator_id. |
referred_by_facilitator_id. Matches the existing referral_source semantics. |
Bikeshed; matters for grep-ability. Rejected: source_facilitator_id collides with case_shares.source_tenant_id mental model. Rejected: attributed_facilitator_id over-promises (attribution can shift; the column captures the referrer). |
| DP-J | Should the tenant_id column on facilitators even exist, given there is exactly one facilitator tenant? |
Yes, keep it. Following the platform's no-special-case discipline. | Pro: future-proofs against per-region facilitator tenants (e.g. tenant-curaway-facilitators-eu). Pro: RLS uniformly applies. Con: one redundant column at v1 — accept the discipline. |
| DP-K | Storage of commission_pct: NUMERIC(5,4) (fraction 0.1500) vs NUMERIC(5,2) (percent 15.00) vs basis points INT. |
NUMERIC(5,4) fraction. |
Pro: no rounding ambiguity — multiply directly by amount_cents. Pro: integer math friendly when amount_cents × commission_pct is computed in #617 (use Decimal per CLAUDE.md "No floating-point currency math"). Rejected: percent-as-display tempts UI bugs. Rejected: basis points = unfamiliar to non-finance reviewers. |
| DP-L | Should Case.referred_by_facilitator_id be eagerly populated for cases created BEFORE the facilitator's first case (i.e. when the patient was attributed after their first case existed)? |
No backfill of existing cases. Only future-case creations propagate. | Pro: preserves DP-C immutability symmetry. Pro: zero SQL beyond DDL. Con: edge case where patient has 1 case (NULL attribution), then admin attributes them → 2nd case picks up the attribution → asymmetric. Acceptable; this maps to the real-world fact that the facilitator was identified mid-relationship. |
9.3 Decision Points consolidated count¶
12 decisions documented (DP-A → DP-L), exceeding the "DP-A through DP-D + at least 4 more" requirement.
Note on DP-E and DP-J: these are discipline confirmations, not genuinely contestable choices — DP-E ("enable RLS like every other tenant-scoped table") is dictated by ADR-0018's defense-in-depth posture, and DP-J ("keep tenant_id even with one facilitator tenant") is dictated by the modular-monolith no-special-case rule. Kept in the table for traceability so the next reviewer doesn't ask "did anyone consider…?" The genuinely contestable DPs are DP-A, DP-B, DP-C, DP-D, DP-F, DP-G, DP-H, DP-I, DP-K, DP-L (10/12).
10. Edge Cases (15+ entries — mandatory)¶
| ID | Scenario | Expected behavior | Verified by |
|---|---|---|---|
| EC-1 | Patient registers with referred_by_facilitator_id referring to a soft-deleted facilitator |
422 FACILITATOR_NOT_FOUND (treated as not found because _active_only filter applies). |
pytest unit: test_register_with_inactive_facilitator_id_rejects |
| EC-2 | Patient registers with referred_by_facilitator_id referring to an is_active=false (but not deleted) facilitator |
422 FACILITATOR_INACTIVE. Distinct from EC-1 so admin can debug "I see them in /admin/facilitators but registration fails." Implementation note: to return distinct error codes the validation step must do a two-pass lookup — first include is_deleted=true rows to detect EC-1, then check is_active to detect EC-2. The plain _active_only query collapses both into 404. Document in the service layer. |
pytest unit |
| EC-3 | Patient registers with malformed UUID in referred_by_facilitator_id |
422 from Pydantic UUID validation, before reaching the service. | pytest unit |
| EC-4 | Admin creates facilitator with already-used email (active row) | 409 FACILITATOR_DUPLICATE_EMAIL. The ux_facilitators_email_lower partial unique index enforces. |
pytest unit + integration |
| EC-5 | Admin creates facilitator with email that previously existed but was soft-deleted | Allowed — partial unique index excludes is_deleted=true rows. New row has new id. Old rows' attributions stay intact. |
pytest unit |
| EC-6 | Clerk webhook fires for a facilitator whose email isn't in facilitators table yet |
Log WARNING, emit Telegram alert (app/services/alerting.py), return 200 to Clerk (don't retry storm). Admin manually creates the row, then re-fires the webhook OR runs an idempotent backfill. |
Mock test + manual rehearsal |
| EC-7 | Clerk webhook fires for a facilitator whose clerk_user_id is already set to a different value |
Log WARNING + Telegram alert, return 200, do NOT overwrite. Surfaces "Clerk user got their account swapped" — needs human intervention. | pytest unit on attach_clerk_user_id |
| EC-8 | GET /facilitator/sourced-cases called by a Clerk user whose user_id doesn't match any facilitators.clerk_user_id |
Return APIResponse([]) (empty list, 200). NOT 404. The user is authenticated but unknown to the attribution layer. |
pytest integration |
| EC-9 | Case created when Patient.referred_by_facilitator_id is NULL → admin later attributes the patient |
Old case keeps Case.referred_by_facilitator_id = NULL. New cases pick up the new value. DP-L. |
pytest integration |
| EC-10 | Admin PATCH on facilitator changes commission_pct from 10% → 15% |
New value applies to new cases only. Existing cases' future commissions (in #617) freeze the pct at accrual time — that's #617's responsibility, not D13's. D13's job: never recompute Case.referred_by_facilitator_id. |
DOC: update PATCH 200 response with note in OpenAPI |
| EC-11 | Admin DELETE (soft) on facilitator who has 0 attributed patients/cases | Allowed without admin:force. is_active=false, is_deleted=true. Audit row emitted. |
pytest unit |
| EC-12 | Admin DELETE (soft) on facilitator who has ≥1 attributed patient/case, no admin:force |
409 FACILITATOR_HAS_ATTRIBUTED_RECORDS. |
pytest integration |
| EC-13 | Admin DELETE with admin:force on attributed facilitator |
Allowed; soft-deletes; attribution columns retain the (now-soft-deleted) id because of ON DELETE SET NULL on a hard delete only. Soft-delete leaves the FK intact, so historical commissions remain queryable. |
pytest unit |
| EC-14 | Hard-delete via GDPR Article 17 cascade on a facilitator | ON DELETE SET NULL fires; Patient.referred_by_facilitator_id and Case.referred_by_facilitator_id go NULL. Existing commission rows in #617 lose their FK target — #617 spec must handle this independently (orphan-commission cleanup). |
Cross-spec test; document in #617 |
| EC-15 | Patient sends referred_by_facilitator_id referencing a facilitator on a different tenant (someone fabricating a UUID from another deployment) |
The cross-tenant validation read uses BaseUnscopedRepository.get_by_id_any_tenant which returns the row regardless of tenant — but the row's tenant_id MUST equal tenant-curaway-facilitators per a service-layer assertion. Else 422 FACILITATOR_NOT_FOUND (don't leak existence). |
pytest integration |
| EC-16 | Two concurrent admin POSTs creating same email | DB unique index on LOWER(email) enforces; second one gets IntegrityError → wrapped as DuplicateRecordError by BaseRepository._execute → 409. |
pytest concurrency test |
| EC-17 | Race: facilitator gets soft-deleted between patient registration validation and INSERT | Insert succeeds (FK still valid, soft-delete doesn't change id). Patient ends up attributed to a soft-deleted facilitator. Acceptable — admin can correct via PATCH. Mitigation: tighten by using SELECT ... FOR UPDATE on the validation read; deferred unless real-world hit. |
Documented; not blocking |
| EC-18 | RLS context not set (current_setting('app.tenant_id', true) returns NULL) on a facilitators query |
Postgres returns 0 rows (RLS policy condition is false). Application sees empty result. The BaseRepository._scoped_query would have already raised TenantIsolationViolation if tenant_id was empty — so this only fires if app code bypasses the repo. Acceptable defense-in-depth. |
Manual: psql with no SET app.tenant_id |
| EC-19 | Migration runs on a fresh DB where tenant-curaway-facilitators row doesn't exist |
Migration A inserts the tenants row idempotently before creating facilitators. ON CONFLICT (id) DO NOTHING makes it safe to re-run. |
Alembic upgrade dry-run on fresh DB |
| EC-20 | Migration runs on prod where the FF rbac_enforcement is OFF |
Permission grants still apply (they're DB rows, not FF-gated). When FF flips ON later, the perms are already there. No-op race. | Manual on staging |
| EC-21 | Patient.referral_source = "facilitator-x" (free-text legacy) AND Patient.referred_by_facilitator_id = <uuid> simultaneously |
Both stored. referral_source stays informational; structured FK is canonical for commissions. No migration tries to reconcile them. |
Documented in §8.3 |
| EC-22 | Patient is bulk-imported by admin (future feature) without a facilitator FK | NULL — same as DP-D default. No special handling. | n/a (future) |
| EC-23 | Patient is created by the conversation flow (/api/v1/patients) — no referred_by_facilitator_id in payload |
Defaults to NULL. The patient-app must send the cookie value via referred_by_facilitator_id field if they want attribution. D5/#618 wires the cookie reader. |
pytest integration |
| EC-24 | Audit log row not emitted on facilitator update (audit_log table down) | _audit() adds to session; if db.flush() fails, the whole txn rolls back. Facilitator update fails atomically. Aligns with RoleRepository behavior. |
pytest with mocked AuditLog failure |
| EC-25 | Sourced-cases endpoint called with a facilitator_id resolved to a soft-deleted facilitator (race: admin deletes during request) |
The lookup WHERE clerk_user_id = :user_id AND is_deleted=false returns no row → empty list response. User sees no cases. Acceptable; matches EC-8. |
pytest unit |
| EC-26 | Cross-tenant query in get_sourced_cases reads cases table while the patient's tenant has RLS enabled |
Use BaseUnscopedRepository for the cases query (no app.tenant_id GUC requirement; bypasses RLS). Audit log emits with actor_type='system' per the BaseUnscopedRepository contract. |
pytest integration with RLS turned on locally |
| EC-27 | Webhook attempts to attach clerk_user_id that's already taken by another facilitator row |
ux_facilitators_clerk_user_id partial unique index → IntegrityError → wrapped as DuplicateRecordError → 409 from webhook handler → Telegram alert. |
pytest unit |
27 edge cases documented; 15+ requirement met.
11. Validation Plan¶
11.1 Unit tests (pytest, tests/unit/)¶
tests/unit/repositories/test_facilitator_repository.py- get_by_id (happy + tenant-mismatch returns None)
- get_by_email_lower (case-insensitive)
- create with audit emission (assert AuditLog row added)
- update with before/after audit payload
- soft_delete sets is_deleted+is_active correctly
- attach_clerk_user_id rejects overwrite
tests/unit/services/test_facilitator_service.py- create rejects duplicate email (409 mapping)
- delete with attributed records + no force → raises
FacilitatorHasAttributedRecordsError - delete with attributed +
admin:force→ soft-deletes tests/unit/schemas/test_facilitator_admin_schemas.py- commission_pct out of [0,1] → ValidationError
- currency_code regex
- email RFC 5322
11.2 Integration tests (pytest, tests/integration/)¶
tests/integration/routers/test_admin_facilitators.py- super_admin can POST/PATCH/GET/DELETE
- platform_admin can POST/PATCH/GET/DELETE
- facilitator (the role) gets 403 on
/admin/facilitators - patient role gets 403
- PATCH idempotency via
X-Idempotency-Key - X-Tenant-ID validation (must be
tenant-curaway-facilitatorsfor non-super-admins) tests/integration/routers/test_facilitator_sourced_cases.py- facilitator with 0 cases → empty list
- facilitator with 3 cases → 3 items, ordered DESC by
Case.created_at - facilitator without
clerk_user_idresolution → empty list (EC-8) - non-facilitator role on the endpoint → 403
- feature flag OFF → 403 FEATURE_DISABLED
- pagination (page=2, page_size=2)
tests/integration/test_patient_register_with_facilitator_attribution.py- register with valid
referred_by_facilitator_id→ patient has FK - register with non-existent UUID → 422
- register with soft-deleted facilitator → 422 (EC-1)
- register with cross-tenant facilitator → 422 (EC-15)
tests/integration/test_case_creation_propagates_attribution.py- create case for attributed patient →
Case.referred_by_facilitator_idmatches - mutate
Patient.referred_by_facilitator_idthen create new case → new value (DP-L) - mutate after old case exists → old case unchanged (DP-C)
11.3 Migration tests¶
tests/migrations/test_facilitators_migration.py(new file in this PR's scope)- upgrade on fresh DB: assert table exists, RLS enabled, perms granted
- upgrade is idempotent (run twice, no error)
- downgrade on populated DB: assert tables/columns/perms removed
- assert
tenant-curaway-facilitatorsrow inserted - assert 41 patients still have NULL
referred_by_facilitator_idpost-upgrade
11.4 Permission grant coverage¶
tests/test_permission_grant_coverage.py(existing, per824b672ec7a4_…:67-70) — extend its scanner to assertfacilitator_admin:manageandfacilitator:sourced-cases:readare each granted to ≥1 role.tests/test_route_access_scanner.py— verifyGET /admin/facilitators*andGET /facilitator/sourced-casesare gated by@require_permission(no exemption needed; no{case_id}path).
11.5 Zero-data-leakage validation on the existing 41 patients¶
Run as the cutover gate:
-- 1. Migration applied without disturbing existing rows:
SELECT COUNT(*) AS attributed_patients
FROM patients
WHERE referred_by_facilitator_id IS NOT NULL;
-- Expected: 0
-- 2. Migration applied without disturbing existing cases:
SELECT COUNT(*) AS attributed_cases
FROM cases
WHERE referred_by_facilitator_id IS NOT NULL;
-- Expected: 0
-- 3. The new tenant row exists exactly once:
SELECT COUNT(*) FROM tenants WHERE id = 'tenant-curaway-facilitators';
-- Expected: 1
-- 4. The new facilitators table is empty until first POST:
SELECT COUNT(*) FROM facilitators;
-- Expected: 0
-- 5. Permission grants applied:
SELECT code, permissions FROM roles
WHERE code IN ('facilitator', 'platform_admin', 'super_admin');
-- Expected: facilitator includes "facilitator:sourced-cases:read";
-- platform_admin + super_admin include "facilitator_admin:manage"
-- 6. RLS active on facilitators:
SELECT relname, relrowsecurity FROM pg_class WHERE relname = 'facilitators';
-- Expected: relrowsecurity = TRUE
Run on staging first, then prod. Capture output to deploy log.
11.6 Code-review subagent gates (per .claude/rules/definition-of-done.md)¶
/code-review— spec compliance + code quality (Tier 3 mandatory)compliance-reviewer.md— confirm GDPR cascade hook updated forfacilitatorstablearchitecture-reviewer.md— Tier 3 featureplatform-integrity-checker.md— multi-tenancy + auth verification
11.7 Manual smoke test (cutover step)¶
- Apply migration on staging.
POST /api/v1/admin/facilitatorswith SD's gmail → expect 201, row visible in/admin/facilitatorsGET.PATCH /api/v1/admin/facilitators/{id}setting commission_pct=0.15 → expect 200, audit row emitted.- Register a fresh test patient (
tenant-curaway-patients) withreferred_by_facilitator_id=<the new id>→ expect 201, patient row has FK populated. - Trigger case creation for that patient → expect new
Case.referred_by_facilitator_idpopulated. - Sign in as SD on
facilitator.curaway.ai(post-Clerk-org provisioning per portal spec) → callGET /facilitator/sourced-cases→ expect 1 item. DELETE /api/v1/admin/facilitators/{id}withoutadmin:force→ expect 409 (because patient/case attached).DELETEwith super_admin (admin:forcein scope) → expect 200,is_deleted=true. Patient/Case FKs preserved (soft-delete doesn't fireON DELETE SET NULL).
12. Implementation Tier Breakdown (Opus / Sonnet / Haiku)¶
Per .claude/rules/definition-of-done.md.
12.1 Opus¶
- This spec itself (architecture, schema, decisions).
- ADR-0018 amendment (~10 lines clarifying
FacilitatorPartnershipwas always implicit; this spec formalizes the schema). - Permission grant migration design (security boundary).
- Cross-tenant FK + RLS interaction review (EC-15, EC-26).
- Code review on every Tier-3 PR.
12.2 Sonnet¶
app/models/facilitator.py(new model)app/repositories/facilitator_repository.py+ unscoped variant (mechanical from spec)app/services/facilitator_service.py(orchestration)app/schemas/facilitator_admin.py(Pydantic)app/routers/admin_facilitators.py(mirrorsadmin_tenants.py)- Extension to
app/routers/facilitator_portal.pyforGET /sourced-cases - Extension to
app/routers/patients.pyregistration (add optional field) - Extension to
app/services/patient_service.py(validation + propagation to Case) - Pytest units + integration tests per §11
- Alembic migration A (single file, ~150 lines)
12.3 Sonnet or Haiku¶
config/rbac_defaults.yaml— add 2 perm strings toplatform_admin+facilitator+super_adminconfig/feature_flags.yaml— addfacilitator_sourced_cases_enabled- Run
scripts/sync_flagsmith.py --dry-run docs/CHANGELOG.mdAPI changelog entrydocs/api/error-codes.md— addFACILITATOR_*error codes- Update
docs/runbook/erasure.mdGDPR cascade to includefacilitators - README touch-ups in
app/repositories/if a domain index exists
12.4 Recommended PR sequencing¶
- PR 1 (Opus): this spec merged + DPs resolved (DP-A through DP-L).
- PR 2 (Sonnet): model + migration + unscoped+scoped repos + unit tests. Migration runs in staging.
- PR 3 (Sonnet): service + admin router + admin integration tests. Permission grants live.
- PR 4 (Sonnet): patient registration extension + Case propagation + integration tests. Backwards compatible (field optional).
- PR 5 (Sonnet): facilitator-portal
/sourced-casesendpoint + integration tests. FF gated. - PR 6 (Haiku): docs sync, error codes, GDPR runbook update.
Each PR is independently revertable — no PR depends on a later PR for correctness.
13. Migration / Rollout (zero-downtime)¶
13.1 Pre-cutover (no UI exposure)¶
- PR 1 (this spec) — merged. SD signs DPs.
- PR 2 — migration applied on staging via
alembic upgrade headwithDATABASE_URL_ADMIN. Run §11.5 SQL checks. - PR 2 — migration applied on prod during a low-traffic window. NULL-able columns + IF NOT EXISTS make this online-safe at 41 patients. Verify §11.5 SQL on prod.
- PR 3-4 — admin endpoints live; SD POSTs first facilitator (themselves) for end-to-end smoke. No frontend exposure yet.
- PR 5 —
/sourced-casesendpoint live; FFfacilitator_sourced_cases_enableddefaults OFF fortenant-curaway-facilitators. Existing endpoints continue to work unchanged.
13.2 No breaking changes to existing endpoints¶
POST /api/v1/patients/register— adds OPTIONAL field; existing callers (patient-app) continue to omit it.GET /api/v1/facilitator/delegated-cases— completely unchanged.POST/GET/DELETE /api/v1/consent/facilitator/*— completely unchanged.- All admin routers — no shape changes to existing endpoints.
13.3 First facilitator (validation user)¶
- SD invites their own email (
srikanth.donthi@gmail.comper CLAUDE.md context) into thetenant-curaway-facilitatorsClerk org asorg:admin. - Backend admin POSTs the corresponding
facilitatorsrow in advance (DP-A decoupled flow). Email matches. - Clerk webhook fires on signup →
clerk_user_idpopulated. - SD signs into
facilitator.curaway.ai→ portal-spec PortalOrgGate passes →/casesempty → manually flipfacilitator_sourced_cases_enabledfortenant-curaway-facilitatorsON → register a test patient withreferred_by_facilitator_idset to SD's facilitator id → refresh/sourced-cases→ row appears.
13.4 Rollback plan¶
- Frontend break: doesn't apply — this spec is backend-only. Portal already ships separately.
- Backend break:
- Soft: flip
facilitator_sourced_cases_enabledOFF →/sourced-casesreturns 403 FEATURE_DISABLED. Admin endpoints stay live. - Harder: revert the
admin_facilitatorsrouter via deploy rollback. Migration stays applied (NULL columns are harmless). - Hardest:
alembic downgrade -1if data corruption emerges. Reversible per §7.2. Caveat: if any patient has a non-NULLreferred_by_facilitator_idat downgrade time, the column drop will lose that data — accept the risk or hand-export first.
13.5 GDPR Article 17 cascade update¶
docs/runbook/erasure.md(verify exists) — appendfacilitatorsto the cascade plan. Hard-delete order:- Reverse soft-delete (set
is_deleted=true) - Wait for retention window (per CLAUDE.md "Audit logs are append-only — facilitator audit rows MUST NOT be deleted, but the facilitator entity itself can be hard-erased after retention")
- Hard-delete the row →
ON DELETE SET NULLcascades toPatient.referred_by_facilitator_idandCase.referred_by_facilitator_id.
Critical distinction: soft-delete (is_deleted=true) preserves the FK on patients/cases — historical commissions stay queryable. Only hard-delete fires ON DELETE SET NULL. This is intentional (EC-13 + EC-14): admins routinely soft-delete; only the GDPR pipeline ever hard-deletes.
- Update the deletion-certificate template to include facilitator-attribution NULLification.
13.6 Observability¶
- Telegram alerts on:
- Clerk webhook failures attaching
clerk_user_id(EC-6, EC-7, EC-27) FacilitatorHasAttributedRecordsErrorraised (potential admin error or compliance event)- DB unique index violations during admin POST (rare, indicates UI race)
- Langfuse: not applicable (no LLM calls in this spec).
- Metabase: add a tile "Active facilitators by tenant" once data exists. Out of scope but trivial follow-up.
- Grafana: no new external dependencies; no health panel needed.
14. Out of Scope / Future Work¶
| Item | Why deferred | Tracking |
|---|---|---|
| Commission ledger model + endpoints | This is #617 / D4. Depends on facilitators.id. |
Issue #617 — references this spec |
| Referral link infrastructure | This is #618 / D5. Depends on the new Patient.referred_by_facilitator_id column. |
Issue #618 — references this spec |
| Self-service facilitator profile editing (their own name/phone/notes) | DP-G; v1 is admin-managed. | Future spec — facilitator:profile:write:own perm |
Per-procedure or per-tenant commission_pct overrides |
DP-B; flat-pct ships first. | Future spec — facilitator_commission_overrides table |
| Multi-user-per-facilitator (agency model) | v1 is one Clerk user per row. Extending requires facilitator_members join table. |
Future spec — depends on real-world demand |
| Admin retroactive bulk-tag endpoint | DP-D; not needed at 41 patients. | Future spec if SD wants it |
Backfill / parse Patient.referral_source free-text into structured FKs |
§8.3; brittle, low value. | Won't fix unless data volume forces it |
Neo4j edges (:Patient)-[:REFERRED_BY]->(:Facilitator) |
Postgres-only v1; matching engine doesn't need it yet. | Future spec — Neo4j sync |
| Facilitator self-service portal of "my sourced cases" KPIs (volume, conversion) | UI work; backend's /sourced-cases ships the data. |
Frontend follow-up after this PR — sister card to portal-spec D2 |
| Audit log filter for "show me all facilitator.update events for facilitator X" | Existing audit_logs schema already supports it; just needs an admin UI. |
Admin portal phase 4 |
| Facilitator-side ratings GET endpoint | Already deferred per portal spec D3. Independent of attribution. | #244 follow-up |
Per-tenant facilitator (e.g. tenant-curaway-facilitators-eu) |
DP-J kept the column for future-proofing but no tenants planned. | Future spec — region split |
15. References¶
Backend models¶
app/models/patient.py:1-92— Patient model; line 80 =referral_source: str(100); lines 27-29 =external_auth_idapp/models/case.py:1-124— Case model; line 67 =patient_idFK stringapp/models/case_share.py:1-144—actor_type='facilitator'(line 84), dual-tenant scope (lines 75-80)app/models/role.py:1-139— RoleCode enum; line 35 =FACILITATOR = "facilitator"app/models/tenant.py:1-113— Tenant + TenantOrgMapping + TenantSettingsapp/models/audit.py:1-65— AuditLog (append-only)app/models/base.py:1-72—TenantScopedMixin,UUIDPrimaryKeyMixin,TimestampMixin,FlexibleJSON
Repositories¶
app/repositories/base.py:38-186—BaseRepository(_scoped_query,_audit,_active_only)app/repositories/base.py:189-237—BaseUnscopedRepositoryapp/repositories/role_repository.py:19-37—RoleRepository(BaseUnscopedRepository)referenceapp/repositories/role_repository.py:71-117—assign_roleaudit emission patternapp/repositories/patient_repository.py:1-90— Patient repo CRUD shape
Services¶
app/services/patient_service.py:30-75—register_patient— extension point for new fieldapp/services/facilitator_consent_service.py:211-245—get_delegated_cases(post-#614 fix (closes issue #614);target_tenant_idfilter)
Routers¶
app/routers/patients.py:58-100—POST /patients/registerextension pointapp/routers/facilitator_portal.py:148-181— existingdelegated-casesendpoint to addsourced-casesnext toapp/routers/admin_tenants.py:1-80— admin CRUD shape to mirror
Middleware¶
app/middleware/require_permission.py:31-100— perm decoratorapp/middleware/rbac_middleware.py:59-77—_ORG_TENANT_MAP_FALLBACK;tenant-curaway-facilitatorsnot yet present
Migrations¶
alembic/versions/cab789eed1ca_add_phase0_rbac_and_case_shares.py:96-273— full migration shape (CREATE, RLS, GRANT, seed, downgrade)alembic/versions/824b672ec7a4_grant_portal_permissions_to_roles.py:88-152— JSONB perm-append idempotent shapealembic/versions/5d49162011a5_add_tenant_curaway_patients_row.py— tenant-row insert patternalembic/versions/c8d3f2a91b4e_enable_rls_on_llm_usage.py— current head (verify on PR)
Config¶
config/rbac_defaults.yaml:17-23— facilitator role definitionconfig/feature_flags.yaml:269-272—facilitator_consent_enabledconfig/feature_flags.yaml:298-302— ratings/CSAT flag
ADRs¶
docs/adr/0018-multi-tenancy-platform-architecture.md:11— 7-actor modeldocs/adr/0018-multi-tenancy-platform-architecture.md:20— facilitator row "1 shared, row-level by facilitator_id"docs/adr/0018-multi-tenancy-platform-architecture.md:34— "Reference (live, patient-scoped) … Consent-gated"docs/adr/0018-multi-tenancy-platform-architecture.md:42— facilitators vs coordinators distinctiondocs/adr/0018-multi-tenancy-platform-architecture.md:249-256—hospital_recovery_partnerships.commission_pct(NOTFacilitatorPartnership)docs/adr/0018-multi-tenancy-platform-architecture.md:454— facilitator portal Phase 3
Specs¶
docs/specs/facilitator-portal-frontend-feature.md§5.4b — original audit (this spec formalizes the fix)docs/specs/facilitator-portal-frontend-feature.md§10 D13 — decision-point this spec implementsdocs/specs/multi-tenancy-phase3-coordinators-facilitators-impl.md:48-51— Phase 4 deferral contextdocs/specs/per-provider-tenants-feature.md:1-90— pre-flight ground-truth pattern this spec mirrors
Project context¶
CLAUDE.md— "All money in USD cents", "Multi-Tenancy + Per-Resource Authorization from Day 1", "Feature Flags Everywhere", "No PII in logs".claude/rules/coding-principles.md— simplicity, surgical changes.claude/rules/backend-services.md— DAO pattern, multi-tenancy, no cross-domain imports.claude/rules/definition-of-done.md— Tier 3 feature checklist
Last updated: 2026-05-04.