Skip to content

Facilitator Portal Frontend — Feature Spec

Status: Draft — pending SD review Author: Claude (Opus, planning role) — 2026-05-03 Target path: docs/specs/facilitator-portal-frontend-feature.md Related epic: #244 (portal completion epic) Related ADR: ADR-0018 — Multi-Tenancy + 7-Actor Platform Architecture Reference router: app/routers/facilitator_portal.py (lines 1–181)


1. Goal + Non-Goals

Goal

Ship apps/facilitator-app/ — a Vite + React 18 + Clerk SPA that gives external Curaway facilitators (independent referral agents per ADR-0018 row 20) a self-service portal to:

  1. See cases that patients have delegated to them.
  2. Manage the consent grant/revoke flow for their own facilitator-id (where applicable — see §10 Decision Points).
  3. View ratings they've received from patients.
  4. (Stretch, behind feature flag) Track commissions and manage referral links.

The portal must be visually and architecturally indistinguishable from apps/{admin,coordinator,mso,provider}-app/ — same ClerkProvider, same PortalOrgGate, same ApiClient from @curaway/shared-core, same Teal/Coral/Deep-Ocean palette, same Montserrat + Crimson Pro typography, same 8px grid, same WCAG AA target.

Non-Goals (deferred)

  • Commission ledger backend — no model, no router, no service exists today (grep -li commission app/ returns only app/seed_graph.py, where it appears as a Neo4j relationship label, not application logic). See §10 Decision Points D4 and §12 Out of Scope.
  • Referral link backend — no referral_link/referral_code table, no public landing page wired to attribution. See §10 Decision Points D5.
  • Case-source attribution layer — no facilitators table, no Patient.referred_by_facilitator_id FK, no Case.referred_by_facilitator_id. Today only Patient.referral_source (free-text, max 100 chars) exists. See §5.4b + D13. This is a deeper hole than D4/D5 alone: without attribution, commissions can't be paid and referral links have nothing to attribute to. Must ship before Phase 4 lifts the placeholders.
  • Patient delegation REQUEST inbox — the current API exposes only /consent/facilitator/list (patient-side: who has access to me) and /facilitator/delegated-cases (facilitator-side: cases granted to me). There is no concept of a pending invitation/request in the data model — CaseShare.is_active is binary (granted or not). See §10 D2 and §12.
  • Facilitator-side ratings GET endpoint — only /api/v1/coordinator/{coordinator_id}/performance exists (app/routers/coordinator_portal.py:695). It is hard-coded to coordinator_id semantics. See §10 D3.
  • Patient roster / case detail viewer beyond the share metadata — the DelegatedCaseItem schema (app/schemas/facilitator.py:60-67) only returns share_id, case_id, source_tenant_id, consent_granted, created_at. Rich case detail (procedure, patient demographics, status) requires per-case access through case_access middleware which currently exempts facilitators. Out of scope until Phase 4.
  • In-portal messaging / chat with patient or coordinator — Phase 4.
  • Mobile-native app — responsive web only (375px tested).
  • i18n / non-English — English-only, with dir="auto" on text containers per .claude/rules frontend section.
  • Onboarding / KYC of new facilitators — invite-only, manual provisioning by platform admin (see §6 Tenant + Auth).

2. User Stories

# As a... I want... So that...
US-1 Facilitator who just signed in to see a list of all cases delegated to me by patients I've referred I can confirm the platform sees the relationship and I have something to act on
US-2 Facilitator with multiple delegated cases to see when each delegation was granted and whether consent is still active I know which patients have not revoked me and I can prioritize live cases
US-3 Facilitator who lost access mid-session to see a clear "consent revoked" empty state and a path to ask the patient to re-grant I don't get a 403 page and assume the platform is broken
US-4 Facilitator viewing my ratings to see the average rating across all cases plus the individual feedback strings I can improve and prove my service quality to prospective referrals
US-5 Facilitator with a referral link (Phase 4 / flagged) to copy a unique URL I can text to a prospective patient I get attribution credit when they sign up

User stories US-1, US-2, US-3, US-4 are deliverable on the existing backend. US-5 is gated behind future-work backend (D5).


3. Architecture

3.1 Repo location

  • App folder: apps/facilitator-app/ in curaway-health-navigator
  • Package name: @curaway/facilitator-app (mirrors apps/admin-app/package.json:2)
  • Workspaces: monorepo root package.json already discovers apps/* (no change needed there)
  • Shared deps: @curaway/shared-core (ApiClient, resolveTenantId, ApiError) + @curaway/shared-web (PortalOrgGate, SettingsPage, cn util)
  • Bundler: Vite 5 + @vitejs/plugin-react-swc — copy apps/admin-app/vite.config.ts, change dev port. Verified existing allocations: patient=8080, admin=8081, coordinator=8081 (pre-existing collision, not in scope to fix here), provider=8082, design-system=8083, mso=8084. Pick facilitator=8085 (next free).

3.2 Vercel project

Portal Vercel project name Subdomain
admin curaway-admin admin.curaway.ai
coordinator curaway-coordinator coordinator.curaway.ai
mso curaway-mso mso.curaway.ai
provider curaway-provider provider.curaway.ai
facilitator (new) curaway-facilitator facilitator.curaway.ai

Confirm project name with SD before vercel link. SD-decision: D7.

3.3 Required env vars (mirror apps/coordinator-app/.env.example)

VITE_CLERK_PUBLISHABLE_KEY=pk_live_…  (or pk_test_… for dev)
VITE_API_BASE_URL=https://services.curaway.ai
VITE_TENANT_ID=tenant-curaway-facilitators   # local-dev fallback only

VITE_TENANT_ID is the local-dev fallback — production uses resolveTenantId(orgId, publicMetadata) from packages/shared-core/src/tenant-map.ts:161 which prefers Clerk org publicMetadata.tenant_id then the runtime org→tenant map.

3.4 Source tree (target)

apps/facilitator-app/
├── index.html
├── package.json              # mirrors apps/admin-app/package.json
├── postcss.config.js         # copy
├── tailwind.config.ts        # copy (no edits)
├── tsconfig.json             # copy
├── vercel.json               # copy verbatim — security headers + SPA rewrites
├── vite.config.ts            # copy, port 8084
├── .env.example              # tenant-curaway-facilitators
└── src/
    ├── App.tsx               # ClerkProvider + PortalOrgGate(portal="facilitator")
    ├── App.test.tsx
    ├── main.tsx              # identical to all 4 portals
    ├── index.css             # copy admin (or coordinator) — tokenized HSL vars
    ├── lib/
    │   └── api-client.ts     # useApiClient + useTenantId — IDENTICAL pattern
    ├── hooks/
    │   ├── useApiQuery.ts    # copy verbatim from coordinator-app
    │   └── useFacilitatorApi.ts
    ├── services/
    │   └── facilitatorApi.ts # service class wrapping ApiClient
    ├── components/
    │   ├── ApiErrorBoundary.tsx     # copy verbatim from coordinator-app
    │   └── FacilitatorLayout.tsx    # icon rail + main + UserButton
    ├── pages/
    │   ├── Auth.tsx                 # split-pane brand panel (right) + sign-in (left)
    │   ├── DelegatedCases.tsx       # primary landing (US-1, US-2, US-3)
    │   ├── CaseDetail.tsx           # share metadata + revoke-status awareness
    │   ├── DelegationRequests.tsx   # rendered as "All delegations" until D2 resolved
    │   ├── Ratings.tsx              # gated behind D3 / FF
    │   ├── Commissions.tsx          # gated behind FF (default off, D4)
    │   ├── ReferralLinks.tsx        # gated behind FF (default off, D5)
    │   └── Settings.tsx             # uses shared SettingsPage(portal="facilitator")
    └── test/
        └── setup.ts                 # jest-dom setup, copy from coordinator-app

3.5 Routing (mirrors apps/coordinator-app/src/App.tsx:69-78)

/  → redirect to /cases
/auth                   (signed-out only — split tab Sign In / Sign Up)
/cases                  DelegatedCases (default landing)
/cases/:caseId          CaseDetail
/delegations            DelegationRequests (single list — pending vs active = D2)
/ratings                Ratings (FF gated — see D3)
/commissions            Commissions (FF gated — D4)
/referrals              ReferralLinks (FF gated — D5)
/settings               SettingsPage(portal="facilitator")

Routes outside the FF-disabled set fall back to <Navigate to="/cases" replace />.

3.6 Shared-package contract is already facilitator-aware

  • packages/shared-web/src/components/auth/PortalOrgGate.tsx:38 declares 'facilitator' as a PortalType and provides label "Facilitator" at line 54. Picker filters publicMetadata.portal_type === 'facilitator' org memberships at line 110-115. Zero changes required in the shared package.
  • packages/shared-core/src/tenant-map.ts:40-52 does not currently ship a static seed for the facilitator org. This must be fixed in two places (defense-in-depth, mirroring the other portals):
  • Provisioning step (mandatory): publicMetadata.tenant_id = "tenant-curaway-facilitators" set on the Clerk org at creation time (§6.3 step 2). resolveTenantId reads publicMetadata.tenant_id first.
  • Static seed (mandatory): add (<org_clerk_facilitator_id>, "tenant-curaway-facilitators") to ORG_TENANT_MAP in tenant-map.ts:40-52 AND to _ORG_TENANT_MAP_FALLBACK in app/middleware/rbac_middleware.py so the resolution doesn't depend on the dynamic /public/tenant-map fetch on first render or while offline. See §9 Edge Cases EC-3.

4. Backend Integration Table

4.1 Endpoint × page × action matrix

Endpoint File:line Permission Tenant context Page that calls it Action
POST /api/v1/consent/facilitator/grant app/routers/facilitator_portal.py:46 consent:facilitator:grant X-Tenant-ID from active Clerk org NOT called by facilitator portal. Patient-app only — request.state.user_id is the patient id. See §10 D1. n/a
POST /api/v1/consent/facilitator/revoke app/routers/facilitator_portal.py:83 consent:facilitator:revoke header NOT called by facilitator portal — patient-app calls. n/a
GET /api/v1/consent/facilitator/list app/routers/facilitator_portal.py:116 consent:facilitator:list header NOT called by facilitator portal (lists from the patient's perspective). n/a
GET /api/v1/facilitator/delegated-cases app/routers/facilitator_portal.py:148 case:read:delegated X-Tenant-ID (facilitator's tenant) + JWT-resolved user_id (request.state.user_id) maps to facilitator_id DelegatedCases.tsx, DelegationRequests.tsx, CaseDetail.tsx Initial load + retry
POST /api/v1/ratings/submit app/routers/coordinator_portal.py:657 rating:submit header NOT called by facilitator portal. Patient-app submits ratings. n/a
GET /api/v1/coordinator/{coordinator_id}/performance app/routers/coordinator_portal.py:695 coordinator:performance:read header Ratings.tsxonly if SD agrees to reuse the endpoint with actor_id=facilitator_id (D3). Aggregated CSAT view

4.2 Realized backend surface for the facilitator portal

Effectively one endpoint: GET /api/v1/facilitator/delegated-cases.

The other three endpoints in facilitator_portal.py are patient-facing (the JWT subject is the patient, who grants/revokes/lists facilitators on their own cases). They live in this router by domain ("facilitator consent flow") but are not wired into the facilitator's own UI.

This is the most important finding of the spec — see §10 D1.

4.3 Response envelope

Every endpoint returns the standard { success: true, data: ... } envelope. The shared ApiClient.unwrap() at packages/shared-core/src/api-client.ts:28-43 strips the envelope, so service-layer code receives data directly. List endpoints return data: DelegatedCaseItem[] (the router uses [item.model_dump() for item in items] at line 180).

4.4 Required headers (every request)

  • Authorization: Bearer <Clerk JWT> — set by ApiClient.headers() (packages/shared-core/src/api-client.ts:56-64)
  • X-Tenant-ID: <resolved tenant> — set by ApiClient when constructed with non-empty tenantId
  • X-Correlation-ID: <uuid> — auto-generated per request (line 61)
  • Content-Type: application/json — for write endpoints

4.5 Error envelope shapes (handled by ApiClient + ApiErrorBoundary)

  • 401 — Clerk JWT missing or expired (refresh failed). ApiErrorBoundary.tsx:38-44 redirects to /auth.
  • 403 AUTH_PERMISSION_DENIED — RBAC fail (app/middleware/require_permission.py:78-85)
  • 403 FEATURE_DISABLEDis_feature_enabled("facilitator_consent_enabled", tenant_id) returned false (facilitator_portal.py:34-40). ApiErrorBoundary.tsx:52-61 renders this as the friendly "Not yet available" panel.
  • 404 — case share not found (revoke path)
  • 500 / network — generic retry surface

5. Page-by-Page UX

5.1 Page 1 — DelegationRequests.tsx (route: /delegations)

Purpose: Single chronological list of "every patient who has delegated me." Until D2 is resolved, there is no notion of a pending request — every record returned by /facilitator/delegated-cases already has consent_granted=true (the service filters at facilitator_consent_service.py:227-228). So the "inbox" framing from the #244 epic copy is aspirational; we render what the API gives us.

Data model (per row):

interface DelegatedCaseItem {
  share_id: string;            // CaseShare.id
  case_id: string;             // Case.id
  source_tenant_id: string;    // tenant the patient lives in
  consent_granted: boolean;    // always true today (filter in service)
  created_at: string | null;   // ISO 8601 UTC
}

Endpoint: GET /api/v1/facilitator/delegated-cases

Primary action: click row → navigate to /cases/:case_id (CaseDetail).

Secondary actions: - Sort by created_at (desc default) - Filter by source_tenant_id (chip — only shown if >1 tenant in result set) - Refresh button (calls retry from useApiQuery)

Edge states: - Loading: skeleton rows via <SkeletonRows count={5}/> (component already exists at apps/coordinator-app/src/components/ApiErrorBoundary.tsx:16-24 — copy) - Empty: "No cases delegated to you yet. Patients you refer will appear here once they grant consent." Icon: LayoutList (lucide-react, already imported by other portals) - Error 500/network: ApiErrorBoundary shows "Failed to load data" with Retry — already wired - 403 FEATURE_DISABLED: "Not yet available" copy — already wired - 403 AUTH_PERMISSION_DENIED: auto-redirect to /auth — handled by ApiErrorBoundary.tsx:38-44 - Unauthorized (401): auto-redirect to /auth

Mobile (375px): - Single-column list (no table). Each row stacks: case_id (mono small), source_tenant chip, "granted X days ago" relative timestamp. - Touch target ≥44px (already enforced by .claude/rules/frontend). - Filter chips collapse into a "Filters (n)" sheet.

shadcn/ui components reused (already installed in @curaway/shared-web): - Card, Button, Badge — consistent with coordinator portal CaseQueue tab styling - Custom FilterChip — copy from apps/coordinator-app/src/pages/CaseQueue.tsx:101-181 - SkeletonRows from coordinator's ApiErrorBoundary.tsx

5.2 Page 2 — DelegatedCases.tsx (route: /cases, default landing)

Functionally identical to DelegationRequests for v1. The split into two pages exists to match the #244 epic phrasing, but until the backend grows a "pending request" concept (D2) they show the same data with different framing copy:

  • /delegations headline: "Patient delegations" / subtitle: "Cases shared with you"
  • /cases headline: "My cases" / subtitle: "Cases you're actively managing"

Recommendation (D2): consolidate into one route until backend distinction exists. SD to confirm.

5.3 Page 3 — CaseDetail.tsx (route: /cases/:caseId)

Purpose: show the limited share-level metadata for a single delegated case. Today this is only the fields in DelegatedCaseItem — no procedure name, no patient name, no status timeline, because the facilitator role does not currently pass case_access middleware ownership checks.

Data model: fetch /facilitator/delegated-cases on mount (do NOT assume the list page populated a cache — deep-link refresh of /cases/:caseId is a real flow), then filter client-side by case_id. (No /facilitator/delegated-cases/{case_id} endpoint exists.)

Primary action: none — read-only view.

Secondary actions: - Copy case_id button - Link out to coordinator (mailto / future messaging — D8)

Edge states: - Case_id not in result set: "This case is no longer shared with you. The patient may have revoked your access." — distinct from generic 404 because the API never returned 404; we just didn't see the row. Show with ShieldOff icon. - Stale data (consent revoked between list load and detail click): same copy. - All standard loading/error states.

Mobile (375px): - Single-column metadata list. share_id truncated with copy button. Created-at as relative + absolute on long-press.

5.4 Page 4 — Ratings.tsx (route: /ratings, FF-gated)

Purpose: show ratings the facilitator has received from patients.

Backend dependency (D3): NO endpoint currently exposes facilitator-side aggregated ratings. /api/v1/coordinator/{coordinator_id}/performance is permission-gated by coordinator:performance:read (granted to coordinator role only — see alembic/versions/824b672ec7a4_grant_portal_permissions_to_roles.py:91). The SQL underneath (rating_service.py:80-108) does in fact filter only by actor_id and tenant_id, NOT by actor_type, so the data would come back correctly if we passed a facilitator_id — but the route is named after the coordinator semantically, the permission is coordinator-only, and the response field is coordinator_id.

Two options for SD (D3): 1. Reuse the endpoint — grant the coordinator:performance:read permission to the facilitator role too, rename the endpoint to /api/v1/actors/{actor_id}/performance, and add actor_type filtering server-side. Backend ticket required. 2. Hide page until v2 — render "Ratings coming soon" placeholder; ship the route behind FF facilitator_ratings_enabled (default off).

This spec defaults to option 2 — see §10 D3.

If/when option 1 lands: - Data: PerformanceResponse (app/schemas/facilitator.py:129-136): total_ratings, average_rating, total_cases_completed, ratings: RatingResponse[] - Layout: copy apps/coordinator-app/src/pages/Performance.tsx 1:1 — KPI tiles (Average, Total ratings, Cases completed) + scrollable rating row list with star renderer + feedback text. - Edge states: 0 ratings → "No ratings yet. They'll appear here after patients submit feedback."

5.4b Case-source attribution — gap analysis (audit 2026-05-03)

Before any of §5.5 (Commissions) or §5.6 (Referral Links) can ship, we must answer: can we identify cases generated by facilitators vs direct patients? Audit verdict: no, not today. What exists in the data model:

Surface What it captures What it doesn't
Patient.referral_source: str(100) (app/models/patient.py:80) Free-text source string set at registration (e.g. "google_search") No FK, no schema, no facilitator entity
CaseShare row with actor_type='facilitator' Post-hoc delegation — patient grants the facilitator access to an already-existing case NOT source attribution. The case was created independently; the share record only proves the facilitator was later granted read access.
roles[code='facilitator'] + CaseShare.actor_id Role grant + Clerk user_id No facilitators table, no FK from Patient or Case to a structured facilitator record

Implication: the DelegatedCases page (US-1) accurately answers "which cases am I authorized to see?" but cannot answer "which cases did I generate?" — that distinction has no database backing today. Commissions (D4) and Referral Links (D5) both implicitly assume an attribution layer that doesn't exist.

Required attribution layer to unblock Phase 4 (now tracked as D13 in §10): 1. New facilitators table — id, tenant_id (= tenant-curaway-facilitators), name, clerk_user_id, commission_pct, is_active, audit cols. Decoupled from the roles system so a facilitator entity can pre-exist their Clerk signup (invite-only flow). 2. New column Patient.referred_by_facilitator_id: str | None (FK → facilitators.id). Backfill: NULL for all 41 existing patients (direct signups). Set at registration via referral cookie (D5 dependency) or admin override. 3. (Optional, performance) denormalize to Case.referred_by_facilitator_id on creation — saves a JOIN through Patient for commission queries. 4. New endpoint GET /api/v1/facilitator/sourced-cases (separate from delegated-cases) — lists cases the facilitator originated, regardless of whether they currently hold a CaseShare for them.

Until D13 ships, Commissions + Referral Links must remain placeholders; the DelegatedCases page is functionally correct but mislabelled if SD intends "facilitator-originated cases" semantics.

5.5 Page 5 — Commissions.tsx (route: /commissions, FF-gated, default OFF)

Backend dependency (D4 + D13): no commission ledger model, no endpoint, no service. Confirmed by grep -li commission app/ — only app/seed_graph.py (Neo4j relationship label, not application logic). AND no source-attribution layer (§5.4b / D13) — without it, commissions can't be tied to specific facilitators.

Spec for this page is to render a placeholder card behind feature flag facilitator_commissions_enabled (must be added to config/feature_flags.yaml per .claude/rules/coding-principles.md "Feature Flags Everywhere"). Copy:

"Commission tracking is coming in Phase 4. We'll show your earned commissions, payout schedule, and historical statements here once it's ready."

Backend work required to lift the placeholder (not in this PR's scope — file as a separate spec): - Commission model with: id, tenant_id, facilitator_id, case_id, amount_cents (USD per CLAUDE.md "All money in USD cents"), currency_code, status (pending, accrued, paid), accrued_at, paid_at - GET /api/v1/facilitator/commissions — permission commission:read:own (new perm, must be granted to facilitator role) - GET /api/v1/facilitator/commissions/summary — totals by status - Cascade rule for case-cancellation → commission reversal - Connection to existing FacilitatorPartnership.commission_pct (already in schema per ADR-0018 line 253)

5.6 Page 6 — ReferralLinks.tsx (route: /referrals, FF-gated, default OFF)

Backend dependency (D5): no referral_link table, no public attribution endpoint. Same placeholder pattern as Commissions, behind FF facilitator_referral_links_enabled.

Backend work required (separate spec): - ReferralLink model: id, tenant_id, facilitator_id, slug (URL-safe unique), landing_path, utm_source/utm_medium/utm_campaign, is_active, created_at, expires_at - GET /api/v1/facilitator/referral-links — list own - POST /api/v1/facilitator/referral-links — create - PATCH /api/v1/facilitator/referral-links/{id} — toggle active - Public marketing-side: GET /r/{slug} redirects to /?ref=<id> with attribution cookie (1-year TTL) → patient-app reads cookie at signup and sets Patient.referral_facilitator_id (new column)


6. Tenant + Auth

6.1 Tenant resolution path

  1. User signs into facilitator.curaway.ai with their Clerk credentials.
  2. Clerk returns active organization via useOrganization() (apps/admin-app/src/lib/api-client.ts:18).
  3. PortalOrgGate(portal="facilitator") (packages/shared-web/src/components/auth/PortalOrgGate.tsx:159-198) checks organization.publicMetadata.portal_type === "facilitator":
  4. Match: render children
  5. No match / no active org / multiple orgs: show filtered org-picker filtered to memberships where publicMetadata.portal_type === "facilitator"
  6. No facilitator memberships at all: <NoAccessPanel portal="facilitator"> (line 70) — copy already says "Your account isn't a member of any facilitator organization."
  7. Once the gate passes, useTenantId() calls resolveTenantId(organization.id, publicMetadata):
  8. First tries publicMetadata.tenant_id (preferred — admin sets it during onboarding).
  9. Falls back to runtime org→tenant map (static seed in packages/shared-core/src/tenant-map.ts:40-52 plus dynamic fetch from GET /api/v1/public/tenant-map).
  10. ApiClient is constructed with the resolved tenant and starts attaching X-Tenant-ID: <resolved> to every request.

6.2 Tenant naming (D1)

Working name: tenant-curaway-facilitators (matches the prefix pattern tenant-curaway-{ops|patients} used for other shared-fleet tenants — see packages/shared-core/src/tenant-map.ts:46, tenant-map.ts:65 and rbac_middleware.py:63).

Why a single shared facilitator tenant (mirrors ADR-0018 row 20: "1 shared, row-level by facilitator_id"): - Consistent with the model: facilitators are not tenant-isolated from each other (they share clinical context with their patients via CaseShare, not their own data silos). - Simplifies seeding — one row in tenants, one mapping in tenant_org_mappings, one Clerk org. - Matches the existing pattern for tenant-curaway-ops (coordinators).

SD must confirm name (D1).

6.3 Clerk org provisioning

  1. Platform admin creates a Clerk organization "Curaway Facilitators" (one org, all facilitators are members).
  2. Sets publicMetadata:
    {
      "portal_type": "facilitator",
      "tenant_id": "tenant-curaway-facilitators"
    }
    
  3. Adds row to tenant_org_mappings: (org_id, tenant_id, portal_type) = (org_<clerk>, "tenant-curaway-facilitators", "facilitator").
  4. Invites individual facilitators by email — Clerk sends invite, facilitator signs up, Clerk auto-joins them to the org.
  5. Backend RBACMiddleware (app/middleware/rbac_middleware.py:83-141) resolves the org → tenant on every request. The facilitator role + the 3 portal permissions (consent:facilitator:grant/list/revoke) + case:read:delegated are already seeded in roles table per migration 824b672ec7a4 (lines 100-103) and cab789eed1ca.

6.4 Invite-only signup

The Auth.tsx page must include both Sign In and Sign Up tabs (consistent with admin/coordinator/mso/provider per apps/admin-app/src/pages/Auth.tsx:78-114), but signup without a Clerk org invite produces a user with zero org memberships, which PortalOrgGate already handles via <NoAccessPanel/> ("ask a Curaway admin to add you").

Copy adjustments for facilitator Auth page: - Sign-in headline: "Facilitator Portal" (replaces admin's "Ops Dashboard") - Sign-up headline: "Request Facilitator Access" (mirrors admin's "Request Access") - Branding panel feature bullets: - "Track patient delegations in one place" - "Earn commission on completed referrals" (only if D4 unblocked) - "View patient ratings & feedback" - "Generate trackable referral links" (only if D5 unblocked) - Right panel uses Coral accent dots on Deep Ocean background — same as admin (apps/admin-app/src/pages/Auth.tsx:118-146).

6.5 JWT claim handling

Standard Clerk JWT — RBACMiddleware already pulls org_id, org_role, user_id (sub) and resolves permissions. No new claims needed. The frontend never inspects JWT contents — it forwards the bearer token via ApiClient.headers().

6.6 Facilitator hasn't been onboarded yet

Two cases: - No Clerk account: the user lands on /auth?tab=signup, signs up — Clerk creates the user but with no org memberships. PortalOrgGate shows <NoAccessPanel portal="facilitator"/> ("Ask a Curaway admin to add you"). Resolution: SD/admin manually invites them via Clerk dashboard. - Clerk account exists, no facilitator org membership: same NoAccessPanel outcome.


7. Permissions Matrix

Permissions resolved from @require_permission(...) decorators in app/routers/facilitator_portal.py cross-referenced with the 824b672ec7a4 grant migration:

API call Permission required Granted to (role) Granted in (file:line) Used by (page)
POST /consent/facilitator/grant consent:facilitator:grant facilitator alembic/versions/824b672ec7a4_grant_portal_permissions_to_roles.py:101 (none — patient-app concern; listed for completeness)
POST /consent/facilitator/revoke consent:facilitator:revoke facilitator 824b672ec7a4_grant_portal_permissions_to_roles.py:103 (none — patient-app)
GET /consent/facilitator/list consent:facilitator:list facilitator 824b672ec7a4_grant_portal_permissions_to_roles.py:102 (none — patient-app)
GET /facilitator/delegated-cases case:read:delegated facilitator (via seed) config/rbac_defaults.yaml:21, alembic/versions/cab789eed1ca_add_phase0_rbac_and_case_shares.py:38 DelegatedCases, DelegationRequests, CaseDetail
POST /ratings/submit rating:submit patient (and coordinator for peer) 824b672ec7a4_grant_portal_permissions_to_roles.py:107, :97 (none — patient-app)
GET /coordinator/{id}/performance coordinator:performance:read coordinator, platform_admin, super_admin 824b672ec7a4_grant_portal_permissions_to_roles.py:91, 111-112 Ratings — but facilitator is NOT granted this perm (D3 dependency)

Per-resource authorization gap: case_access.py:115 notes that facilitators are one of the "actor_types that reach case [...]", but the comment continues that they require special exemption handling. The current /facilitator/delegated-cases endpoint does not use Depends(require_case_access) — it filters by actor_id == request.state.user_id in the service (facilitator_consent_service.py:224-228). Adding a /facilitator/delegated-cases/{case_id} detail endpoint would need either an exemption in tests/test_route_access_scanner.py or case_access middleware support for facilitators (which isn't there today). This is why §5.3 CaseDetail filters client-side instead of round-tripping for detail.

Missing tenant_id filter — backend pre-req (BLOCKING): facilitator_consent_service.py:219-232 accepts a tenant_id parameter on get_delegated_cases but the SQL WHERE clause filters only on actor_id, actor_type, consent_granted, is_activetenant_id is never used. Today this is dormant because Clerk user_id is globally unique, but it's a real per-resource auth gap: the row read isn't tenant-scoped, only actor-scoped. Must be filed as a backend ticket and fixed before §11.4 end-to-end loop validation, otherwise a misconfigured actor_id (or a future-spec where actor IDs aren't globally unique, e.g. external partner orgs) reads across tenants. See §8 EC-23 and §10 D12 (new).


8. Edge Cases (mandatory per .claude/rules/coding-principles.md)

ID Scenario Expected behavior Verified by
EC-1 Facilitator's only org membership has publicMetadata.portal_type"facilitator" PortalOrgGate renders NoAccessPanel (already handled at PortalOrgGate.tsx:117-118) Manual + Vitest
EC-2 Facilitator has multiple facilitator-portal orgs (rare — multi-agency role) PortalOrgGate renders the filtered picker (already handled) Manual
EC-3 First page-load before loadDynamicTenantMap() resolves and Clerk org has no publicMetadata.tenant_id useTenantId() returns '' momentarily → ApiClient omits X-Tenant-ID header → backend returns 403 → ApiErrorBoundary redirects to /auth Test: load with mocked slow /public/tenant-map. Mitigation: ensure facilitator org always has publicMetadata.tenant_id set at provisioning so we never depend on the dynamic fetch.
EC-4 facilitator_consent_enabled flag flipped off mid-session for the tenant Subsequent requests return 403 with error_code=FEATURE_DISABLEDApiErrorBoundary.tsx:52-61 renders friendly "Not yet available" panel Manual test by toggling Flagsmith
EC-5 Patient revokes consent while facilitator is on CaseDetail page Next useApiQuery retry filters the case out → CaseDetail's "case no longer in result set" copy fires Manual: 2-window test
EC-6 RBAC role facilitator removed from Clerk org membership while user has the page open Next API call returns 403 AUTH_PERMISSION_DENIEDApiErrorBoundary redirects to /auth Manual
EC-7 Network timeout (≥10s, no response) fetch rejects → useApiQuery sets error → ApiErrorBoundary shows "Failed to load data" with Retry Manual offline test
EC-8 API returns 500 / 502 Same as EC-7 — generic error pane with Retry Manual + Playwright
EC-9 Empty result set (new facilitator with zero delegations) Empty state message renders, NOT a crash Vitest with mocked empty payload
EC-10 API returns malformed envelope (e.g. data: null) ApiClient.unwrap() returns null → page treats as empty state Vitest
EC-11 Stale Clerk JWT (expired) getToken() from useAuth auto-refreshes; if refresh fails, request 401 → ApiErrorBoundary redirects to /auth Manual
EC-12 Two browser tabs open, one revokes via Clerk session, the other still tries to call API 401 on next request → redirect to /auth Manual
EC-13 Same case_id appears twice in response (data quality bug) List render uses share_id as React key — no duplicate-key warning. UI shows two rows. Code review (use share_id not case_id for keys)
EC-14 created_at is null (DB allows it per schema created_at: datetime \| None) Render "—" or "Unknown" — never throw on new Date(null) Vitest
EC-15 source_tenant_id is unfamiliar string (cross-tenant share, e.g. patient-app tenant) Display as opaque tenant ID — do not attempt to resolve to a human name (PII risk) Code review
EC-16 Facilitator clicks Settings → SettingsPage gets portal="facilitator" but the shared component wasn't tested with this value Pre-flight verification: check packages/shared-web/src/components/settings/SettingsPage.tsx accepts 'facilitator' as a valid portal prop. If not, add it as a one-line change. TBD — verify in implementation
EC-17 Facilitator-app deployed before tenant-curaway-facilitators is seeded in tenants + tenant_org_mappings Every request 403s on tenant validation → users locked out. Mitigation: §11 rollout step 1 must be the seed migration before any DNS cutover. Migration required first
EC-18 User signs up via /auth?tab=signup without an invite, lands on NoAccessPanel, but signs up first then asks SD to invite — second login shows correct portal Confirmed flow already works (PortalOrgGate re-evaluates on every render) Manual
EC-19 Multi-tab race: facilitator clicks Retry in tab A while tab B's request also retries Both run concurrently — last write wins. useApiQuery cancellation token guards stale state. Code already uses cancelled flag (useApiQuery.ts:24-42)
EC-20 Clock skew > 60s between client and server breaks Clerk JWT validity Clerk SDK auto-refreshes; on persistent failure, normal 401 → /auth flow Manual
EC-21 Facilitator user is a member of two facilitator-portal orgs across different tenants PortalOrgGate shows the org-picker filtered to facilitator memberships; user picks one per session; switching requires explicit re-pick. No implicit tenant blending. Manual + Vitest
EC-22 CaseShare.is_active=false (revoked) vs row deleted entirely Service filters on is_active=True (facilitator_consent_service.py:228) — both behave the same to the facilitator UI. Backend policy: prefer soft-delete (is_active=false) so audit trail is preserved. Hard-delete is reserved for GDPR Article 17 erasure. Code review
EC-23 Cross-tenant actor_id collision in get_delegated_cases (no tenant_id filter on SQL) Today: dormant because Clerk user_ids are globally unique. After: BLOCKING fix per §10 D12 + §7 backend pre-req. UI does not need a separate edge case once the filter is added. Backend test must assert tenant_id filter

9. Validation Plan

9.1 Manual smoke tests (after first deploy, run these in order)

  1. Hit https://facilitator.curaway.ai/ while signed-out → redirects to /auth?tab=signin.
  2. Sign in with a facilitator-org-member account → lands on /cases.
  3. With a facilitator account that has zero delegations → empty state shows.
  4. Seed a CaseShare row directly via psql (or invite a patient to grant) → refresh → row appears in list.
  5. Click row → /cases/:caseId renders share metadata.
  6. Toggle facilitator_consent_enabled to false in Flagsmith for tenant-curaway-facilitators → refresh → "Not yet available" panel.
  7. Toggle back on → list reappears.
  8. Sign out via UserButton → returns to /auth.
  9. Mobile viewport (375px) — verify all 5 pages render without horizontal scroll.
  10. Tab through nav with keyboard — focus rings are visible (Teal #008B8B).

9.2 Vitest unit/component tests

  • App.test.tsx — renders Clerk-key-missing fallback when VITE_CLERK_PUBLISHABLE_KEY is empty (mirrors apps/admin-app/src/App.test.tsx)
  • pages/DelegatedCases.test.tsx — empty state, error state, success state, sort, retry
  • pages/CaseDetail.test.tsx — "case no longer shared" path, copy-id button
  • pages/Ratings.test.tsx — placeholder when FF off; full layout when FF on (skip until D3)
  • services/facilitatorApi.test.ts — service constructor, getDelegatedCases happy path + error mapping
  • components/FacilitatorLayout.test.tsx — nav active states, UserButton present

9.3 Playwright E2E (add file e2e/facilitator-portal.spec.ts)

Matches the existing e2e/coordinator-api.spec.ts shape. Required env: E2E_FACILITATOR_URL (add to playwright.config.ts:11 projects array), E2E_API_URL, auth state file at e2e/.auth/facilitator.json.

Test cases: 1. Login flow — navigate to /, redirected to /auth, sign in via storage state, land on /cases. 2. Empty state E2E — facilitator with no shares sees the empty copy. 3. Happy-path delegations — list returns ≥1 row, click → CaseDetail renders. 4. Permission-denied — temporarily strip case:read:delegated (test fixture), expect 403 → /auth redirect. 5. Multi-portal smoke — same Clerk user signed in to mso.curaway.ai cannot view facilitator portal (PortalOrgGate blocks).

9.4 Code-review subagent gates (per .claude/rules/definition-of-done.md)

  • /code-review — spec compliance + code quality (Tier 3 mandatory)
  • /impeccable audit — accessibility + responsive + theming
  • /polish — final visual pass
  • Architecture review by architecture-reviewer.md subagent (Tier 3 feature)

10. Decision Points for SD

ID Decision Default proposed Why this matters
D1 Tenant name tenant-curaway-facilitators Single shared tenant matches ADR-0018 row 20. Affects every request header, RBAC seed data, Vercel env. Must be set BEFORE cutover.
D2 "Patient delegation requests inbox" — does it mean active shares only, or do we add a backend concept of pending invitations? Ship as "All active delegations" only. Defer pending-invitation flow. The current model has no pending state — consent_granted is binary. Adding pending requires backend schema + workflow change. Either accept the simpler v1 framing, or file a separate spec for the pending-invitation feature.
D3 Ratings page — reuse /coordinator/{id}/performance (rename + grant perm) or hide behind FF? Hide behind FF facilitator_ratings_enabled (default off) for v1. Reusing requires backend rename + perm grant + endpoint contract change. Cleaner to ship the empty placeholder and follow up with a focused backend ticket.
D4 Commission tracking — backend dependency? Yes, blocks page rendering. Render placeholder card behind FF until backend ships. No model, no router, no service. ADR-0018 mentions FacilitatorPartnership.commission_pct only. Phase 4 work per multi-tenancy-phase3-coordinators-facilitators-impl.md:50.
D5 Referral link management — backend dependency? Yes. Same placeholder pattern. No table, no attribution flow today. Phase 4.
D6 Vite dev port for facilitator-app 8085 — next free port (patient=8080, admin=8081, coordinator=8081, provider=8082, design-system=8083, mso=8084). Demote to "convention, not a real DP" once committed. Avoid collisions when running multiple portals locally.
D7 Vercel project name curaway-facilitator (matches curaway-{admin,coordinator,mso,provider}) Affects DNS, env-var groups, deploy hooks.
D8 "Contact coordinator" / messaging from CaseDetail Defer to v2. Show case_id as plain copy field. No facilitator-coordinator messaging surface exists.
D9 Should facilitator-app be feature-flagged behind a top-level facilitator_portal_enabled flag (so we can dark-launch the URL)? Yes — gate the entire app's main routes behind facilitator_portal_enabled (default off), flip to on per-tenant for soft rollout. Parallel to how other portals shipped. Aligns with ground rule "Feature Flags Everywhere."
D10 Is the existing facilitator_consent_enabled flag the right gate for the entire portal, or do we want a separate facilitator_portal_enabled? Separate. Consent flag governs the API endpoint family; portal flag governs whether the SPA's primary nav is reachable. Different lifecycle. Different concerns; mixing them couples consent rollout to UI rollout.
D11 Should the patient-app's existing facilitator-consent grant flow be exposed as a UI today, or wait? Out of scope for this spec — but flag for SD: today's /consent/facilitator/grant endpoint has no caller in the patient-app codebase (grep confirms). Patient-app must wire this for the loop to be testable end-to-end. Pre-req for E2E validation step §11.4
D12 Backend pre-req (BLOCKING): add tenant_id filter to FacilitatorConsentService.get_delegated_cases SQL (facilitator_consent_service.py:219-232). Today the parameter is accepted but unused. Yes, file as backend ticket and ship before §11.4. Per-resource auth gap — dormant today (Clerk user_ids globally unique) but real. Must close before any expansion to non-Clerk actor IDs.
D13 Phase 4 BLOCKING for D4 + D5: add facilitator attribution layer — facilitators table + Patient.referred_by_facilitator_id FK (+ optional denormalized Case.referred_by_facilitator_id). File as separate backend spec. Sequencing: D13 lands first → D5 (referral links) lands → D4 (commissions) lands last. Audit (§5.4b) confirmed there is NO mechanism today to identify facilitator-sourced vs direct cases. Patient.referral_source is free-text only; CaseShare is post-hoc delegation, not source. Without D13 the commission flow has no foundation.

11. Implementation Tier Breakdown (Opus / Sonnet / Haiku)

Per .claude/rules/definition-of-done.md "Model Tier for Tasks":

Opus (architecture, design, judgment, security boundaries)

  • This spec itself
  • ADR amendment if D2/D3 forces backend changes
  • The facilitator branding panel copy on Auth.tsx (clinical-adjacent, brand voice)
  • App.tsx ClerkProvider + PortalOrgGate wiring (auth boundary)
  • lib/api-client.ts (security boundary — Clerk JWT + tenant header)
  • Decision on whether to ship Ratings/Commissions/Referrals as placeholders vs. hidden routes
  • Code review for all Tier-3 PRs

Sonnet (implementation from clear spec)

  • All five page components (DelegatedCases, DelegationRequests, CaseDetail, Ratings placeholder, Commissions placeholder, ReferralLinks placeholder)
  • services/facilitatorApi.ts — copy coordinator-app shape, replace endpoints
  • hooks/useFacilitatorApi.ts — 3-line wrapper
  • components/FacilitatorLayout.tsx — copy CoordinatorLayout, swap nav items
  • Auth.tsx page — copy admin's, change feature copy
  • Vitest unit tests
  • Playwright E2E spec

Sonnet or Haiku (mechanical / config)

  • package.json, tsconfig.json, tailwind.config.ts, postcss.config.js, vite.config.ts, vercel.json, index.html, index.css — copy and rename
  • .env.example
  • playwright.config.ts add facilitator project
  • Add facilitator_portal_enabled and facilitator_ratings_enabled and facilitator_commissions_enabled and facilitator_referral_links_enabled flags to config/feature_flags.yaml (per .claude/rules "Configuration Externalization")
  1. PR 1 (Opus) — this spec merged + decisions resolved (D1–D11).
  2. PR 2 (Sonnet) — scaffold apps/facilitator-app/ (config files only, blank App.tsx, Auth route stub) so Vercel can deploy a green build before any page is wired.
  3. PR 3 (Sonnet) — wire ApiClient + DelegatedCases page + tests.
  4. PR 4 (Sonnet) — Auth page polish + FacilitatorLayout + CaseDetail.
  5. PR 5 (Sonnet) — placeholder pages for Ratings/Commissions/Referrals behind FFs.
  6. PR 6 (Opus, if greenlit) — backend tickets for D3/D4/D5 then a follow-up frontend PR to lift placeholders.

12. Migration / Rollout

12.1 Pre-cutover (backend, no UI exposure)

  1. Seed tenant — Alembic migration adds tenant-curaway-facilitators to tenants table. Reversible.
  2. Seed tenant_org_mappings — same migration adds (org_<clerk_facilitator>, "tenant-curaway-facilitators", "facilitator"). Generate the org in Clerk first; capture the org_<...> id.
  3. Verify role seedroles[code='facilitator'] already exists (config/rbac_defaults.yaml:17-23); permissions already granted by migration 824b672ec7a4. No-op verification only.
  4. Add feature flagsconfig/feature_flags.yaml + Flagsmith cloud:
  5. facilitator_portal_enabled default false
  6. facilitator_ratings_enabled default false
  7. facilitator_commissions_enabled default false
  8. facilitator_referral_links_enabled default false Run scripts/sync_flagsmith.py --dry-run per .claude/rules/definition-of-done.md "Configuration Externalization."

12.2 Frontend deploy

  1. Land PR 2 (scaffold) → Vercel auto-deploys to a preview URL → verify build succeeds.
  2. Once PR 3–5 merge, vercel --prod from apps/facilitator-app/ (Vercel CLI). Project: curaway-facilitator. Map facilitator.curaway.ai to the project.
  3. DNS: add CNAME facilitator.curaway.ai → cname.vercel-dns.com (existing Cloudflare zone, same pattern as other portals).

12.3 First facilitator (validation user)

  1. SD invites their own gmail.com (srikanth.donthi@gmail.com) to the Clerk facilitator org as a member with role org:admin.
  2. Visit https://facilitator.curaway.ai/ → sign in → confirm PortalOrgGate passes → empty state on /cases.
  3. Toggle facilitator_portal_enabled to true for tenant-curaway-facilitators only.

12.4 End-to-end loop verification

  1. From the patient-app dev console (or via curl until D11 wires the UI), call POST /api/v1/consent/facilitator/grant with case_id + SD's facilitator user_id → returns 200.
  2. Refresh https://facilitator.curaway.ai/cases → row appears with the granted share.
  3. From patient-app, call /consent/facilitator/revoke → row disappears on next refresh.

12.5 Open to others

  1. Invite 1 external partner facilitator. Ask them to repeat steps 9–12.
  2. Once green, flip facilitator_portal_enabled for tenant-curaway-facilitators to true permanently and remove the soft-launch landing-page banner (if any).

12.5b CASE_ACCESS_EXEMPT scanner gate

Any future facilitator route containing {case_id} (e.g. D8 messaging or a richer /facilitator/cases/{id} detail endpoint) MUST either: 1. Use Depends(require_case_access) after case_access.py is extended for facilitator semantics, OR 2. Be added to CASE_ACCESS_EXEMPT in tests/test_route_access_scanner.py with a tracking-issue link explaining why.

The CI scanner will fail otherwise. Spec sequencing in §11 PR-3/4/5 must verify this on every route addition.

12.6 Rollback plan

  • Frontend break: revert the Vercel deploy via vercel rollback.
  • Backend break: flip facilitator_portal_enabled to false → app shows feature-disabled fallback. Or flip facilitator_consent_enabled for hard-disable of the API endpoint set.
  • Data corruption: Alembic downgrade for the seed migration is symmetric (per 824b672ec7a4_grant_portal_permissions_to_roles.py:130-141 pattern).

13. Out of Scope / Future Work

Item Why deferred Tracking
Commission ledger model + endpoints Backend dependency D4. Phase 4 per multi-tenancy-phase3-coordinators-facilitators-impl.md:50. New issue — file before PR 5
Referral link model + attribution flow Backend dependency D5. Phase 4. New issue — file before PR 5
Facilitator-side ratings GET endpoint Backend dependency D3. Either rename /coordinator/{id}/performance to actor-agnostic or add /facilitator/{id}/ratings. New issue
Pending-invitation concept (real "inbox") No data model today. Requires CaseShare.status = pending|granted|revoked + email invite trigger. New spec
Facilitator-coordinator in-portal messaging No surface in any portal yet (admin/MSO also lack it). Cross-portal messaging spec
Facilitator's view of patient case detail beyond share metadata case_access middleware doesn't accept facilitator role today; tests/test_route_access_scanner.py would need an exemption. Tied to per-resource auth roadmap (#587)
Facilitator org-level permissions self-service (manage their own staff) Requires multi-user-per-facilitator-account; today every facilitator is one user. ADR-0018 Phase 5
Internationalization English-only v1. Standard frontend roadmap
Mobile-native iOS/Android Web responsive only. Out of scope for foreseeable future
Public landing page for the facilitator program (marketing) Different surface (curaway-ai/curaway-frontend patient-app marketing routes). Marketing spec

14. References

Backend

  • app/routers/facilitator_portal.py:1-181 — full router
  • app/schemas/facilitator.py:36-67 — request/response schemas
  • app/services/facilitator_consent_service.py:1-253 — service layer
  • app/middleware/require_permission.py:31-100 — permission decorator
  • app/middleware/rbac_middleware.py:83-141 — org→tenant resolution
  • app/middleware/case_access.py:100-125 — per-resource auth (facilitator gap noted at line 115)
  • app/routers/coordinator_portal.py:657-733 — ratings + performance (cross-domain reference)
  • app/services/rating_service.py:80-108 — rating aggregation logic
  • alembic/versions/824b672ec7a4_grant_portal_permissions_to_roles.py:88-113 — RBAC seed
  • alembic/versions/cab789eed1ca_add_phase0_rbac_and_case_shares.py:38case:read:delegated perm
  • config/rbac_defaults.yaml:17-23 — facilitator role definition
  • config/feature_flags.yaml:269-272facilitator_consent_enabled

Frontend (reference apps)

  • apps/admin-app/src/App.tsx:1-112 — full ClerkProvider + PortalOrgGate pattern
  • apps/admin-app/src/main.tsx:1-11 — entrypoint
  • apps/admin-app/src/pages/Auth.tsx:1-149 — split-pane auth page
  • apps/admin-app/src/lib/api-client.ts:1-34 — useApiClient + useTenantId
  • apps/admin-app/package.json:1-37 — package shape
  • apps/admin-app/vercel.json:1-21 — security headers + SPA rewrites
  • apps/coordinator-app/src/App.tsx:1-91 — alternate routing example
  • apps/coordinator-app/src/components/CoordinatorLayout.tsx:1-50+ — icon-rail layout
  • apps/coordinator-app/src/components/ApiErrorBoundary.tsx:1-109 — full error/loading/empty pattern
  • apps/coordinator-app/src/hooks/useApiQuery.ts:1-55 — generic data hook
  • apps/coordinator-app/src/hooks/useCoordinatorApi.ts:1-25 — API service hook
  • apps/coordinator-app/src/services/coordinatorApi.ts:1-80+ — service class pattern
  • apps/coordinator-app/src/pages/CaseQueue.tsx:1-535 — list page reference (filters, sort, keyboard nav, empty/error states)
  • apps/coordinator-app/src/pages/Performance.tsx:1-60+ — Ratings page reference
  • apps/mso-app/src/App.tsx:1-86 — minimal-routes example
  • apps/provider-app/src/App.tsx:1-88 — alternate Auth routing
  • apps/coordinator-app/.env.example:1-3 — env-var shape

Shared packages

  • packages/shared-web/src/components/auth/PortalOrgGate.tsx:33-198 — portal gating (already supports 'facilitator')
  • packages/shared-core/src/api-client.ts:1-138 — ApiClient + ApiError + envelope unwrap
  • packages/shared-core/src/tenant-map.ts:40-193 — resolveTenantId + dynamic fetch

Project context

  • CLAUDE.md "Brand" section (Teal/Coral/Deep Ocean, Montserrat + Crimson Pro, 8px grid, WCAG AA)
  • docs/adr/0018-multi-tenancy-platform-architecture.md:17-22, 39-42, 250-256, 451-456 — 7-actor model + facilitator tenant + commission_pct + portal scope
  • docs/specs/multi-tenancy-phase3-coordinators-facilitators-impl.md:48-51 — "facilitator commission tracking → Phase 4" deferral
  • playwright.config.ts:7-10, 33-64 — subdomain pattern for portals

Last updated: 2026-05-03.