ADR-0024: Facilitator Lifecycle Policy¶
Status: Accepted Date: 2026-05-05 (Decisions recorded by SD) Session: [current session — leave placeholder] Author: Curaway engineering
Context¶
The Phase 5.2 facilitator-attribution security audit (issues #658, #659, #660, #661, #662;
PR feat/facilitator-deactivation-hardening) surfaced that the platform has no formal
lifecycle policy for facilitator rows. The current implementation has the following
characteristics:
What exists today (before feat/facilitator-deactivation-hardening):
soft_delete_facilitator()inapp/services/facilitator_service.pysetsis_active=False, is_deleted=Trueatomically.- Cascade: only
referral_linksare deactivated (is_active=False) in the same transaction (DP-11).case_sharesare NOT revoked on deactivation — this is the P1 data-access gap tracked in #658. - No reactivation method exists anywhere in the service or repository layers.
- Deactivated-facilitator signup:
register_patientraisesFacilitatorInactiveError(HTTP 422) when a patient presents a referral cookie tied to a deactivated facilitator. No attribution-null fallback and no user-friendly message — #660. - Referral redirect (
/r/{code}) does not short-circuit on inactive facilitator or inactive link — #659. - Consent endpoints do not guard against an inactive facilitator reading/revoking patient-granted consent shares — #661.
What feat/facilitator-deactivation-hardening closes (P1 security gaps):
-
658: cascade
case_sharesrevocation on deactivation (same transaction as¶referral_links). -
659: referral redirect returns HTTP 410 Gone for links owned by deactivated¶
facilitators before cookie is written. -
661: consent read/revoke endpoints return 403 when the acting facilitator is¶
is_active=False.
What remains undecided (this ADR):
The hardening PR closes the immediate security gaps but makes no policy decision about: 1. Whether deactivation is permanent or reversible (Option A vs Option B below). 2. What the correct patient-facing UX is when a deactivated-facilitator referral cookie reaches the signup endpoint (sub-decision, issue #660).
Neither of these has been decided in a prior ADR. ADR-0018 §"Facilitators vs Coordinators" defines the actor model but does not address lifecycle. This ADR fills that gap.
Decision¶
Decision recorded 2026-05-05 by SD: - Gate 1: Option A (one-way soft delete) — YAGNI for MVP scale - Gate 2: A1 (silent attribution drop on deactivated-facilitator signup)
Option A — One-Way Soft Delete (recommended)¶
Deactivation is permanent. Once soft_delete_facilitator() is called:
- Row state:
is_active=False, is_deleted=True. No service method reverses this. - Cascade (via
feat/facilitator-deactivation-hardening): referral_links deactivated, case_shares revoked — both in the same transaction. - Re-engagement: if a deactivated facilitator is re-onboarded, a new facilitator row is created. Historical attribution from the old row is preserved on the original row (commissions, case linkages, patient ratings). The new row starts a clean ledger.
- The operator runbook documents the re-engagement workflow (new row + re-issue of referral links).
Pros:
- Simplest state machine — two boolean flags, no reactivation path, no edge cases
around mid-suspension reads.
- Audit trail is clean: each facilitator row represents one continuous engagement period.
Gaps between rows are visible and intentional.
- Commission ledger integrity: each row has its own ledger; re-engagement starts a new
ledger row rather than resurrecting a potentially disputed one.
- GDPR-safe: is_deleted=True rows are excluded from operational queries by default;
the Phase 5.2 hardening PR confirms all guard predicates use this filter consistently.
Cons:
- Re-engagement does not restore historical attribution across the deactivation boundary.
If a facilitator had introduced patients who are still active cases, those cases keep
the old referred_by_facilitator_id (old row) while the re-engaged facilitator
operates from a new row.
- Commission reports for a re-engaged facilitator require joining two (or more) rows
for the same human. Mitigation: operator runbook + admin UI "linked facilitator rows"
field (metadata_json.linked_facilitator_id) — no schema change required.
- No API-level reactivation: recovery from accidental deactivation requires DB surgery
(direct update) or a super-admin bypass endpoint (not scoped here).
Option B — Reversible Deactivation¶
Two distinct states:
| State | is_active |
is_deleted |
Meaning |
|---|---|---|---|
| Active | True | False | Normal operational state |
| Suspended | False | False | Temporary suspension — reactivatable |
| Deleted | False | True | Permanent removal — no API-level reactivation |
Requires adding a reactivate_facilitator() method to FacilitatorService and
FacilitatorRepository that:
- Flips
is_active=True, is_deleted=False(whereis_deleted=Falseis the suspension invariant — only suspended rows can be reactivated; deleted rows cannot). - On reactivation: cascade re-activate
referral_linksthat were deactivated by the originalsoft_delete_facilitator()call (flipis_active=Trueon those rows). case_sharesare NOT auto-restored on reactivation — patients must re-grant consent post-suspension. This is intentional (patient-sovereignty principle).
Pros: - Preserves attribution continuity across suspensions — re-engagement is a no-op from the commission ledger's perspective. - Operator UX: one admin action to resume a facilitator rather than creating + re-mapping a new row.
Cons:
- More complex state machine: three states vs two, two transition paths (suspend vs delete),
one reversal path. More audit cases to enumerate.
- referral_link cascade re-activation may surprise operators who expected deactivated
links to stay deactivated. Requires careful UX wording ("links will be re-activated").
- Edge case: if a suspended facilitator's referral link was re-used by the platform to
resolve a mid-suspension patient signup (Option A1 flow, see below), re-activating
the link could create attribution ambiguity for that signup period.
- Additional repository layer: reactivate_facilitator() must be added to
FacilitatorRepository, which is currently append-only for is_deleted=True rows.
This adds a new guard predicate (is_deleted=False) that must be enforced consistently
in all relevant query paths.
- Net: meaningfully more test surface and more opportunity for guard-predicate drift.
Recommendation¶
Option A — One-Way Soft Delete, with YAGNI rationale.
At current scale (tens of facilitators, not thousands), the operational cost of
reversibility is not justified. The "new row on re-engagement" model is explicit,
auditable, and familiar from other platforms (Stripe's Customer re-creation pattern).
The metadata_json.linked_facilitator_id field is a zero-cost mitigation for the
commission-join problem.
Revisit this decision if: - Facilitator churn reaches a rate where the new-row overhead is operationally burdensome (> 5 re-engagements/month or > 20 active facilitators), OR - Commission reporting tooling requires single-row attribution continuity for regulatory or contractual reasons.
Sub-decision: Deactivated-Facilitator Signup UX (issue #660)¶
When a patient presents a referral cookie tied to a deactivated facilitator at
POST /api/v1/patients/register, three options exist:
A1 — Silent attribution drop (recommended):
Set referred_by_facilitator_id=NULL in the new Patient row. Log the event at INFO
level with facilitator_id (no PII). Continue patient signup normally.
Rationale: Deactivated facilitators are by definition ineligible to earn commissions on new signups. Blocking or erroring the patient punishes the patient for a platform operational event they have no knowledge of. Attribution loss is acceptable because it applies only to NEW signups after deactivation — existing attributed patients are unaffected (their FK is frozen). Best patient UX. Lowest friction.
A2 — Friendly rejection:
Return a user-visible message: "This referral link is no longer active. You can still
sign up directly." HTTP 422 with error_code: FACILITATOR_REFERRAL_EXPIRED.
Rationale: Honest, but blocks the patient at signup. In practice, patients who followed a referral link weeks ago and are now signing up should not be blocked because a business relationship ended. Operator recovers by asking the patient to sign up without the cookie.
A3 — Status quo (current):
Raise FacilitatorInactiveError (HTTP 422). No message guidance. Worst patient UX —
opaque error, no recovery path for the patient. Not recommended.
Recommendation: A1.
The attribution-loss risk of A1 is bounded: it applies only to new signups after
deactivation. Existing patients' referred_by_facilitator_id values are not touched.
Commission calculations for already-attributed patients are unaffected. Deactivated
facilitators should not receive commissions on post-deactivation signups anyway — silent
drop is policy-aligned.
Implementation: catch FacilitatorInactiveError in register_patient(), log at INFO,
proceed with referred_by_facilitator_id=NULL. A follow-up PR is filed after this
ADR is Accepted (see §Implementation Plan).
Consequences¶
Positive (Option A + A1)¶
- Simple state machine reduces audit surface and test complexity.
- One-way soft delete with cascade (case_shares + referral_links) ensures no data-access leak persists after deactivation.
- Best patient signup UX — deactivated-facilitator referrals result in direct signups, not errors.
- Audit trail per facilitator row is clean and complete.
- No new repository methods required for the base lifecycle (Option A).
Negative¶
- Re-engagement creates a new facilitator row — operators must be aware and use the runbook. Commission reports spanning re-engagement require joining two rows.
- Accidental deactivation has no API-level recovery path — requires DB surgery or a super-admin bypass endpoint (not scoped in this ADR; if needed, file a separate issue).
- Attribution is silently lost for signups that occur after a facilitator is deactivated and before the referral cookie expires in the patient's browser.
Neutral¶
- Operator runbook for facilitator suspension and re-engagement to be documented separately (linked in §References once written).
metadata_json.linked_facilitator_idconvention for commission-join across re-engagement rows requires no schema change — JSONB field already exists.
Implementation Plan¶
| Issue | Scope | PR | Status |
|---|---|---|---|
| #658 | Cascade case_shares revocation on deactivation |
feat/facilitator-deactivation-hardening |
In progress |
| #659 | Referral redirect 410 on inactive facilitator/link | feat/facilitator-deactivation-hardening |
In progress |
| #661 | Consent endpoints guard: inactive facilitator → 403 | feat/facilitator-deactivation-hardening |
In progress |
| #660 | Signup UX: catch FacilitatorInactiveError, null FK, log |
feat/deactivated-facilitator-signup-a1 |
In progress |
| #662 | This ADR (lifecycle policy documentation) | docs/adr-facilitator-lifecycle |
This PR |
Path forward (Option A + A1 confirmed):
- Follow-up PR:
feat/deactivated-facilitator-signup-a1— catchFacilitatorInactiveErrorinregister_patient(), set FK to NULL, log warning, proceed with signup
Alternatives Considered¶
Reversible deactivation (Option B)¶
Deferred to avoid premature complexity at current facilitator scale. The additional
repository guard (is_deleted=False), the cascade re-activation of referral_links,
and the patient-consent re-grant requirement add meaningful test surface without
demonstrable operational benefit until facilitator churn warrants it. See §Option B
above for full analysis.
Hard delete instead of soft delete¶
Rejected. Hard deleting a facilitator row breaks FK references from
patients.referred_by_facilitator_id, case_shares.actor_id,
commissions.facilitator_id, and audit_logs.actor_id. NULLing those FKs loses
financial history (commission ledger integrity). The soft-delete pattern
(is_deleted=True) preserves the row for audit and financial record-keeping while
excluding it from all operational queries via guard predicates. Consistent with ADR-0019
(GDPR erasure cascade), which handles true deletion only when a data-subject erasure
request is received.
Composite state field instead of two boolean flags¶
Replacing (is_active, is_deleted) with a single status: ENUM(active, suspended, deleted)
would clarify the three-state model in Option B. Deferred because: (a) it requires an
Alembic migration on a production table; (b) Option A (recommended) only ever uses two
of the three states, making the migration premature; (c) the two-flag model is already
established across query predicates, indexes, and existing tests. If Option B is adopted
at a later date, the status-enum migration is the right time to introduce this change.
References¶
- Issue #658 — P1: case_share data-access leak on facilitator deactivation
- Issue #659 — P1: referral redirect inactive-facilitator defense
- Issue #660 — UX decision: deactivated-facilitator signup (this ADR's §sub-decision)
- Issue #661 — P1: consent endpoint inactive-facilitator defense
- Issue #662 — ADR for facilitator lifecycle policy (this issue)
- PR
feat/facilitator-deactivation-hardening— closes #658, #659, #661 - ADR-0018 §"Facilitators vs Coordinators" — actor model foundation
- ADR-0019 — GDPR erasure cascade (hard-delete context)
- Phase 5.2 audit — summary in issue #662 conversation log + plan PR #651
app/services/facilitator_service.py—soft_delete_facilitator()implementationdocs/compliance/dpia-facilitator-referral-attribution.md— DPIA covering attribution data flows and consent model- Operator runbook for facilitator re-engagement: to be written after ADR is Accepted