Skip to content

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() in app/services/facilitator_service.py sets is_active=False, is_deleted=True atomically.
  • Cascade: only referral_links are deactivated (is_active=False) in the same transaction (DP-11). case_shares are 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_patient raises FacilitatorInactiveError (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_shares revocation 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)

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 (where is_deleted=False is the suspension invariant — only suspended rows can be reactivated; deleted rows cannot).
  • On reactivation: cascade re-activate referral_links that were deactivated by the original soft_delete_facilitator() call (flip is_active=True on those rows).
  • case_shares are 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_id convention 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 — catch FacilitatorInactiveError in register_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.pysoft_delete_facilitator() implementation
  • docs/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