From 95653de6235d504f3b5e9466a222e696cbc3c3e8 Mon Sep 17 00:00:00 2001 From: owenfisher Date: Sun, 2 Aug 2026 16:17:33 -0700 Subject: [PATCH 01/13] docs: architecture specification for the Rehabify rebuild Nine documents covering product definition, verified current state, data architecture, auth, the composed Deepgram voice pipeline, AI pipelines and observability, the cleanup inventory, the build order, and the decision log. Key decisions: BC/Canada market (PIPEDA), Supabase in ca-central-1 with forced RLS, Supabase Auth, composed Deepgram STT -> GPT-5.6 -> Aura TTS, Langfuse under a no-PHI telemetry contract, vision shelved. Co-Authored-By: Claude --- docs/architecture/00-README.md | 103 +++++ docs/architecture/01-product-definition.md | 231 ++++++++++ docs/architecture/02-current-state.md | 309 +++++++++++++ docs/architecture/03-data-architecture.md | 293 ++++++++++++ docs/architecture/04-auth-access-control.md | 215 +++++++++ docs/architecture/05-voice-pipeline.md | 451 ++++++++++++++++++ docs/architecture/06-ai-pipelines.md | 370 +++++++++++++++ docs/architecture/07-cleanup-plan.md | 483 ++++++++++++++++++++ docs/architecture/08-migration-plan.md | 271 +++++++++++ docs/architecture/09-decision-log.md | 356 +++++++++++++++ 10 files changed, 3082 insertions(+) create mode 100644 docs/architecture/00-README.md create mode 100644 docs/architecture/01-product-definition.md create mode 100644 docs/architecture/02-current-state.md create mode 100644 docs/architecture/03-data-architecture.md create mode 100644 docs/architecture/04-auth-access-control.md create mode 100644 docs/architecture/05-voice-pipeline.md create mode 100644 docs/architecture/06-ai-pipelines.md create mode 100644 docs/architecture/07-cleanup-plan.md create mode 100644 docs/architecture/08-migration-plan.md create mode 100644 docs/architecture/09-decision-log.md diff --git a/docs/architecture/00-README.md b/docs/architecture/00-README.md new file mode 100644 index 0000000..c184622 --- /dev/null +++ b/docs/architecture/00-README.md @@ -0,0 +1,103 @@ +# Rehabify — Architecture + +Rebuild specification. Written 2026-08-02. + +Rehabify is an AI workflow copilot for physiotherapy clinics: structured +pre-visit intake, a clinician brief, a constrained care-plan draft, and +between-visit check-ins. **It accelerates clinical work without replacing +examination or judgment.** + +These documents describe the architecture being built, why each choice was made, +and what each one costs. They are written to be read by someone who was not in +the conversation. + +--- + +## The documents + +| | Doc | What it answers | +|---|---|---| +| 01 | [Product Definition](./01-product-definition.md) | What the product is, the authority boundary, first-release scope, the workflow stages | +| 02 | [Current State](./02-current-state.md) | What exists today, verified — including the security findings | +| 03 | [Data Architecture](./03-data-architecture.md) | Supabase, multi-tenancy, RLS, the Drizzle boundary, storage | +| 04 | [Auth & Access Control](./04-auth-access-control.md) | Identity model, Supabase Auth constraints, where authorization is actually enforced | +| 05 | [Voice Pipeline](./05-voice-pipeline.md) | Composed Deepgram STT → LLM → TTS: topology, turn detection, failure modes, capacity | +| 06 | [AI Pipelines & Observability](./06-ai-pipelines.md) | GPT-5.6 tiering, structured outputs, Langfuse under a no-PHI telemetry contract, evaluation | +| 07 | [Cleanup Plan](./07-cleanup-plan.md) | The deletion inventory — ~12,000 LOC and 25.9 MB, with verdicts and risk | +| 08 | [Migration Plan](./08-migration-plan.md) | Build order, what gets ported, the non-engineering gates | +| 09 | [Decision Log](./09-decision-log.md) | Twelve ADRs, and what they supersede | + +**Reading order.** For the shape of the thing: 01 → 09 → 08. For implementation: +03 → 04 → the relevant pipeline doc. 02 and 07 are reference — read them when you +want to know why something is being replaced rather than fixed. + +--- + +## The decisions, in one place + +| | Decision | +|---|---| +| Market | **British Columbia, Canada.** PIPEDA + BC PIPA. SaaS revenue; billing deferred | +| Platform | **Supabase** — Postgres, Storage, Auth — in `ca-central-1` | +| Tenancy | Shared database, `organization_id`, **forced RLS**, tenancy from the JWT | +| ORM | Drizzle behind a **two-client boundary**; `drizzle-kit push` banned | +| Auth | **Supabase Auth** — staff at AAL2, patients episode-scoped and phone-verified | +| Voice | **Composed** Deepgram STT → GPT-5.6 → Aura TTS. We own the turn loop | +| LLM | **GPT-5.6** — Luna for bounded work, Sol for clinician-facing prose | +| Observability | **Langfuse Cloud** under a no-PHI telemetry contract, CI-enforced | +| Vision | **Shelved** | +| Shape | Modular monolith + a durable worker | + +Full context and consequences for each: [09](./09-decision-log.md). + +--- + +## Four ideas the rest of the docs assume + +**1. The authority boundary is the product, not a compliance layer.** +Rehabify drafts; a physiotherapist approves. It does not diagnose, does not +finalize notes, does not invent treatments outside the approved library, and does +not independently progress, regress, pause, or stop treatment. Every technical +decision here is downstream of that — it is why the voice pipeline is composed +rather than a managed agent, why exercise selection is an enum resolved by exact +ID, and why nothing publishes without an approved plan version. + +**2. Where residency can be bought, buy it. Where it cannot, design around it.** +Canadian residency is unsolved or unconfirmed with every vendor in this stack. +Postgres, Storage, and Auth sit in `ca-central-1`. Observability cannot — no +vendor has a Canadian region — so the answer is to **send no PHI at all**, and +assert that in CI. Speech is where this move is unavailable, which is why the +Deepgram residency conversation is a gating dependency rather than a detail. + +**3. A guarantee that depends on everyone remembering is not a guarantee.** +Three places in this architecture replace a policy with a narrow, enforced choke +point: the ESLint-guarded `db.admin` boundary ([03 §4](./03-data-architecture.md)), +the single `mip_opt_out=true` URL builder ([05 §6](./05-voice-pipeline.md)), and +the telemetry attribute allowlist test ([06 §3](./06-ai-pipelines.md)). Each is +one function or one test, and each stands in for a rule that would otherwise be +documentation. + +**4. Silent failures are the recurring enemy.** +The current repo's RLS policies parse and do nothing. `drizzle-kit push` reports +success and skips policy SQL. Deepgram keyterms fail without an error. Custom +JWT claims go stale for an hour. Signed Storage URLs cannot be revoked. In every +case the system reports success while the guarantee is absent — so the tests in +[08](./08-migration-plan.md) are written *before* the things they protect, not +after. + +--- + +## Status + +Specification. No rebuild code has been written. + +The nine documents are complete. Open items live at the end of each doc; the ones +that block the pilot are consolidated in +[08 §4](./08-migration-plan.md) — and three of them (Supabase's PIPEDA +representation, Deepgram's Canadian residency, OpenAI's DPA coverage) are +conversations with other organizations that should start before the first line of +rebuild code. + +One decision remains explicitly **Proposed** rather than Accepted: +[ADR-010](./09-decision-log.md), `nova-3-medical` over Flux. It is settled by a +bake-off against recorded intake audio, and that should happen before the pilot. diff --git a/docs/architecture/01-product-definition.md b/docs/architecture/01-product-definition.md new file mode 100644 index 0000000..e583fa8 --- /dev/null +++ b/docs/architecture/01-product-definition.md @@ -0,0 +1,231 @@ +# 01 — Product Definition + +> Adapted from `feature-architecture-plan.md` in the `rehabifyy` repository, +> with this rebuild's scope decisions applied: **computer vision is shelved**, +> and the target platform is Supabase + a composed Deepgram voice pipeline. + +--- + +## What Rehabify is + +Rehabify is an AI workflow copilot for physiotherapy clinics. It conducts +structured pre-visit intake, briefs the physiotherapist, drafts a care plan +using that clinician's preferred exercises and rules, and summarizes the +patient's response between appointments. + +It accelerates work without replacing examination or clinical judgment. The +product hierarchy is **time savings → care consistency → patient outcomes**. + +The first customer is a clinician-owner or clinical lead at a small outpatient +musculoskeletal clinic with roughly 2–10 physiotherapists. + +```text +Patient voice or text intake + → Structured pre-visit brief + → Physiotherapist examination and findings + → Clinician-specific plan draft + → Clinician review, approval, treatment-consent attestation + → Patient exercises and check-ins + → Next-review progress summary +``` + +--- + +## The authority boundary + +This is the constraint that shapes every technical decision in this spec. It is +not a compliance afterthought — it is the product. + +**Rehabify may:** collect and organize history, identify missing or +contradictory information, draft documentation, retrieve clinic-approved +exercises, draft plan options, show provenance, summarize trends. + +**Rehabify must not:** diagnose, claim an injury diagnosis, finalize notes +automatically, invent treatments outside the approved library, select treatment +without approval, present unexplained recommendations, or independently +progress, regress, stop, pause, or replace treatment. + +When a patient response matches an explicit clinician-approved threshold, +Rehabify may show **exact reviewed contingency text**, record the patient's +acknowledgement or decision to hold the affected activity, and atomically create +an owned clinical-attention task. **It does not mutate treatment state.** +Clearing a patient hold or changing the plan requires a physiotherapist-approved +replacement plan version. + +Language: `draft`, `consider`, `review`, `supporting finding`, `requires +approval`. + +> **The current codebase violates this boundary.** `POST /api/assessments/save` +> generates a plan and inserts it with `status: 'approved'` and no clinician in +> the loop, and `findClosestSlug` fuzzy-matches model output onto real exercise +> IDs. See [02-current-state.md §5](./02-current-state.md). Both are removed in +> the rebuild. + +--- + +## Users + +**Physiotherapist** — the principal user and the buyer. Needs less repetitive +intake work, faster documentation, plans that reflect their own practice, better +between-visit information, and confidence the system has not invented anything. + +**Patient** — needs low-friction intake, a clear approved plan, short check-ins, +and a way to report issues without receiving autonomous medical advice. + +**Clinic administrator** — clinician invitations, branding, consent language, +exercise-library management, basic usage data. Scheduling, payments, payroll, +insurance claims, and clinic management are out of scope. + +--- + +## First release scope + +One complete vertical slice: English-speaking adults physically in **British +Columbia** meeting a partner clinic's signed inclusion/exclusion criteria for +**non-acute knee complaints**, with a ≤30-exercise clinician-reviewed library. + +Target: a controlled design-partner pilot — 1–2 clinics, 2–5 physiotherapists, +20–50 active episodes. Clinic onboarding may be manual. **The clinic clinical +lead signs the exact pathway boundary before any real-patient episode is +enabled.** + +### Workflow stages + +| | Stage | Summary | +|---|---|---| +| A | Clinic & clinician playbook | Exercise library with provenance, contraindications, dosage bounds, review versions. Clinical lead activates content before it can drive a plan. Each clinician configures preferences; edits create a versioned `PlaybookRuleProposal` requiring confirmation. Nothing becomes a global rule automatically. | +| B | Patient onboarding & consent | Expiring SMS episode link → PHI-free pending session → OTP to the phone in the clinic's record → episode-scoped grant. Before verification the patient sees no identity or clinical information. Data-processing consent only; treatment consent is separately attested by the clinician per plan version. | +| C | Adaptive intake | 8–12 min resumable subjective history. Feels conversational, **is a controlled questionnaire**. Text is a complete fallback, not a degraded one. | +| D | Pre-visit clinician brief | Not a transcript wall. Every material statement links to the answer, transcript segment, and timestamp. Corrections create audit events. | +| E | Assessment workspace | ROM, strength, functional testing, observations, palpation, special tests, working hypothesis. Structured knee template + dictation. Dictated findings are *proposed* and must be verified before plan generation. | +| F | Constrained care-plan composer | Hard constraints in code, soft preferences for ranking, dosage from approved templates, LLM only for rationale and patient wording. Nothing published until a physiotherapist approves the whole version. | +| G | Patient plan | Primary screen is `Today`, not a chatbot. Optional voice guide paces reps/holds/rest. **It never claims to observe or correct form.** One-tap `I'm unsure` creates a clinical-attention task. | +| H | Between-visit check-ins | 10–20s post-session check-in. Threshold crossings show exact reviewed contingency text and create a task — they never change treatment state. | +| I | Next-review briefing | Generated 24h before clinician-entered `next_review_at`. Every assertion labeled: patient reported / system calculated / clinician previously specified / AI suggestion requiring review. | + +### Intake control flow + +```text +Approved question graph + → LLM selects or phrases an allowed next question + → speech-to-text or typed answer + → structured answer extraction + → schema validation + → deterministic clinical-rule checks + → next question or completion +``` + +The LLM **may** phrase questions, clarify ambiguity, confirm dates or locations, +summarize answers, and choose among approved follow-ups. It **may not** create a +question category, diagnose, reassure, recommend an exercise, decide treatment +appropriateness, or invent urgency instructions. + +The patient is **never** told that free text is monitored for emergencies. + +--- + +## Rules engine + +Versioned rules cover required fields, intake branching, escalation, +prerequisites, contraindications, dosage bounds, progression/regression, +equipment, clinician exclusions, plan publication, attention-task creation, and +plan-change approval. + +```json +{ + "rule_type": "exercise_eligibility", + "exercise_id": "exercise_123", + "when": { + "all": [ + {"field": "equipment_available", "contains": "resistance_band"}, + {"field": "clinician_disabled", "equals": false} + ] + }, + "effect": "eligible", + "version": 3 +} +``` + +**The language model cannot bypass this engine.** In the pilot, a contingency +rule may select reviewed content and an attention tier; it cannot mutate +treatment state. + +--- + +## Evaluation targets + +Primary pilot outcome is **measured clinician time saved**: median reduction of +≥10 minutes for an initial visit, ≥5 minutes for a follow-up. + +Quality guardrail: **≥80% of generated plan items approved with no or minor +edits.** Time saved does not count if correction effort increases. + +Golden synthetic cases (normal, ambiguous, safety-rule, valid-plan, +invalid-plan, follow-up) must exist before real deployment. Every prompt, model, +rule, and schema change runs the regression set. + +--- + +## Explicitly deferred + +Computer vision, AI-observed form correction, autonomous diagnosis, any +automatic treatment-state mutation, generated patient-support answers, document +upload or summarization, a general medical chatbot, a full EHR, billing, +scheduling, an exercise marketplace, recovery prediction, provider ranking, +population-level recommendations, microservices, Kubernetes, a custom foundation +model. + +--- + +## Scope conflict — resolved: Canada / BC + +> **Decided 2026-08-02: the target market is British Columbia, Canada.** +> `docs/redesign/` is superseded in full. ADR-011 is **Accepted**. + +The superseded `docs/redesign/` spec and this document assumed **different +countries and different business models**, and the difference was not cosmetic. + +| | `docs/redesign/` (superseded) | This document | +|---|---|---| +| Market | United States | British Columbia, Canada | +| Regulation | HIPAA, BAAs | PIPEDA + BC PIPA / provincial health privacy | +| Revenue | $39/mo SaaS **+ CPT 98977/98980 RTM reimbursement (~$98/patient/mo)** | SaaS only; billing explicitly deferred | +| Vision | Central — MediaPipe + DTW, skeleton streaming for billing-grade verification | Shelved | + +**CPT codes are US Medicare billing codes. They do not exist in the Canadian +system.** With BC confirmed, the entire billing-engine premise — and the unit +economics built on it in `docs/redesign/02-unit-economics.md` — **does not +apply and is discarded.** Revenue is SaaS-only; billing stays deferred. + +### What choosing BC costs us + +This is the expensive option, and the docs should say so plainly. Canadian data +residency is **unsolved or unconfirmed with every vendor in this stack**: + +| | Canadian residency | +|---|---| +| Supabase Postgres / Storage / Auth | ✅ `ca-central-1` | +| Supabase **platform logs** | ❌ almost certainly out-of-region — **the biggest gap** | +| Supabase Edge Functions | ⚠️ global by default; must be pinned with `x-region` | +| Supabase Realtime | ⚠️ residency undocumented | +| Deepgram | ❌ **no Canadian endpoint.** EU and AU only; "servers are exclusively in the United States." Dedicated on AWS `ca-central-1` is plausible but **unconfirmed**, and Enterprise-only | +| Langfuse / Braintrust / LangSmith / Helicone | ❌ **none has a Canadian region** | +| OpenAI | ⚠️ unconfirmed | + +Two consequences run through the rest of these docs: + +1. **Supabase has zero PIPEDA representation anywhere on its site.** Not a + technical blocker, but a **legal-review blocker** that must be cleared before + real patient data lands. Raise it with counsel early. +2. Where residency cannot be bought, it is **designed around** — see the no-PHI + telemetry contract in [06 §3](./06-ai-pipelines.md), which makes the + observability vendors' US hosting moot by never sending them PHI. The same + move is not available for speech, which is why + [05 §6](./05-voice-pipeline.md) treats the Deepgram residency conversation as + a gating dependency rather than a detail. + +Tracked as **ADR-011** in [09-decision-log.md](./09-decision-log.md). + +--- + +*Source: `rehabifyy/docs/feature-architecture-plan.md`, adapted 2026-02-11.* diff --git a/docs/architecture/02-current-state.md b/docs/architecture/02-current-state.md new file mode 100644 index 0000000..1172db0 --- /dev/null +++ b/docs/architecture/02-current-state.md @@ -0,0 +1,309 @@ +# 02 — Current State + +> Verified baseline of **this** repository, February 2026. Everything here was +> read out of the source tree, not inferred from prior docs. Claims carry +> `file:line` references so they can be re-checked. +> +> This document exists to justify the rebuild. It is deliberately unflattering. + +--- + +## 1. Stack as built + +| Layer | Technology | Note | +|---|---|---| +| Framework | Next.js 16.1.3, React 19.2 | `middleware.ts` is renamed `src/proxy.ts` under Next 16 | +| Language | TypeScript 5, Zod 4 | | +| Database | Neon Postgres via `@neondatabase/serverless` ^0.10 | **HTTP driver — no pooling, no transactions** | +| ORM | Drizzle 0.38 (`drizzle-orm/neon-http`) | | +| Auth | `@neondatabase/auth` **0.1.0-beta.21** | beta; Better Auth underneath | +| Voice | `@vapi-ai/web` ^2.0 | GPT-4o + ElevenLabs `sarah` + Deepgram `nova-2`, all via Vapi | +| LLM | `@google/generative-ai` ^0.21, `gemini-2.5-flash` | plain text in/out, no JSON mode | +| Vision | `@mediapipe/tasks-vision`, `1eurofilter`, `dynamic-time-warping` | client-side only | +| State | Zustand 5 (15 stores) | | + +**Size:** 53,633 tracked TS/TSX lines. 105 files under `src/app`, 166 under +`src/components`, 68 under `src/lib`. + +--- + +## 2. Security findings + +These are the findings that make "rebuild" the right call rather than +"refactor". They are ordered by severity. **None of them should be assumed +fixed by the rewrite unless the rewrite explicitly addresses them** — several +are design-level, not bug-level. + +### 2.1 Role is client-controlled — CRITICAL + +`requireAuth()` reads the **`x-demo-role` request header** and returns a +hardcoded PT identity when it equals `"pt"` ([src/lib/auth/server.ts:29](src/lib/auth/server.ts:29)). +Every PT page sends that header from the browser — e.g. +[src/app/pt/layout.tsx:24](src/app/pt/layout.tsx:24), +[src/app/pt/clients/[id]/plan/page.tsx:78](src/app/pt/clients/[id]/plan/page.tsx:78). + +Any client can set it. It grants the PT role, which grants read access to +patient rosters, plans, sessions, assessments, and alerts. + +The gate is `NEXT_PUBLIC_DEMO_MODE`, and **`.env.example:73` ships it as +`true`** ([.env.example:73](.env.example:73)). `src/proxy.ts:7` also returns +`NextResponse.next()` unconditionally in demo mode, disabling page-level auth +entirely. + +### 2.2 Role is never read from the database + +`withAuth(handler, { roles })` reads `user.role ?? 'patient'` straight off the +Neon Auth user object ([src/lib/api/auth.ts:76](src/lib/api/auth.ts:76)). +**Nothing anywhere reads `profiles.role`.** Neon Auth's user object carries no +`role` claim and nothing syncs one. + +Consequence: outside demo mode every user resolves to `patient`, so every +`roles: ['pt','admin']` route returns 403 — the PT product only functions with +the insecure demo path enabled. *(Flagged as high-confidence but not verified +against a live session.)* + +### 2.3 Seven routes read or write patient data with no auth check + +| Route | Exposure | +|---|---| +| `api/session-state` (GET/POST/DELETE) | `GET ?sessionId=` returns **any** session's live state — exercise, rep count, form score — to any caller | +| `api/messages` (GET/POST) | no auth; hardcoded clinical mock content | +| `api/messages/[userId]` (GET/POST) | no auth; returns any conversation for any `userId` | +| `api/messages/[userId]/read` (PUT) | no auth (stub) | +| `api/vapi/webhook` (POST) | **no signature verification** | +| `api/vapi/assessment-webhook` (POST) | **no signature verification**; accepts arbitrary `callId` | +| `api/health` (GET) | no auth, no patient data — acceptable | + +`VAPI_WEBHOOK_SECRET` is declared **required** in +[src/lib/env.ts:37](src/lib/env.ts:37) and `.env.example:37`, and is +**referenced by zero source files.** No route verifies a signature. + +### 2.4 Missing ownership checks on authenticated routes + +| Route | Gap | +|---|---| +| `GET /api/plans/[planId]` | `withAuth` with **no role and no ownership check** — any authenticated user reads any plan by UUID ([route.ts:31](src/app/api/plans/[planId]/route.ts:31)) | +| `PATCH /api/plans/[planId]` | any PT can edit any plan | +| `GET /api/pt/clients/[id]` | gated to pt/admin but **never checks `patient.ptId === user.id`** — any PT reads any patient ([route.ts:25](src/app/api/pt/clients/[id]/route.ts:25)) | +| `getTargetPatientId` | PT/admin may pass any `?patientId=` unchecked ([patient-access.ts:26](src/lib/api/patient-access.ts:26)) | + +`GET /api/pt/clients` (the list) *is* correctly scoped to `ptId = user.id` +([route.ts:22](src/app/api/pt/clients/route.ts:22)) — the detail route just +forgot. + +### 2.5 Row-level security is almost certainly inert + +`001_complete_schema.sql:561-655` enables RLS on 11 tables with ~18 policies +keyed on **`auth.uid()`** — a *Supabase* idiom. This codebase connects to Neon +through `neon-http` as a single database role with no per-request JWT and no +`request.jwt.claims`. Those policies cannot be evaluating a real user. + +Treat the database as having **no row-level protection today**. (Not verified +against the live DB; verify before relying on either conclusion.) + +### 2.6 No tenancy at all + +**No table has an organization, clinic, or tenant column.** Scoping is entirely +`profiles.pt_id` (a self-referencing FK) plus per-row `patient_id`/`pt_id`. +Introducing `organization_id` is a schema-wide change touching every table — +this is the single largest reason the data layer is rebuilt rather than +migrated. + +### 2.7 Onboarding writes identity to localStorage + +`/onboard` writes `userProfile` and `onboardingCompleted` to **localStorage** +and redirects to the assessment ([onboard/page.tsx:19](src/app/(onboard)/page.tsx:19)). +Nothing server-side. The file carries its own TODO acknowledging this. + +Also note: `/assessment/*`, `/onboard`, and **all of `/api/*`** are absent from +the `src/proxy.ts` middleware matcher. + +--- + +## 3. Data layer + +### 3.1 Schema + +14 tables in `src/db/schema/`: `profiles`, `exercises`, `plans`, +`plan_modifications`, `assessments`, `sessions`, `session_notes`, `messages`, +`canned_responses`, `notifications`, `pt_alerts`, `pt_recommendations`, +`achievements`, `user_achievements`, `patient_medical_info`. + +- **Zero Postgres enum types.** Every enum is `text` + a `check()` constraint. +- **JSONB is load-bearing and unvalidated at the DB layer**: `plans.structure` + holds the entire 12-week program; `assessments` has five JSONB columns; + also `sessions.exercises`, `profiles.preferences`, + `exercises.modifications`/`detection_config`. Only `plans.structure` is + Zod-validated, and only on write. + +### 3.2 Two competing migration systems, already drifted + +- `db:push` / `db:generate` → drizzle-kit, writing to + `./src/db/migrations/drizzle` — **a directory that does not exist**. +- `db:migrate` / `db:seed` / `db:mock` → raw `psql` against hand-written SQL. + +`003_mock_data.sql` inserts into **`plan_weeks` and `exercise_results`, which +are not in the Drizzle schema**, and into `assessments` columns (`status`, +`pain_location`, `pain_level`, `goals`, `ai_summary`) that do not exist. **That +file cannot run against the current schema.** It is unknown which shape the +deployed database actually has. + +### 3.3 Connection + +`neon()` HTTP driver ([src/db/neon-client.ts:11](src/db/neon-client.ts:11)), +lazily initialized behind a `Proxy` so build-time imports don't throw. +**No transaction support** — a hard constraint for any workflow needing +atomicity (e.g. "create attention task and pause intake in one commit"). + +--- + +## 4. Voice — what is actually wired + +The declared architecture and the running code disagree. + +### 4.1 Three dead definitions, one live path + +| File | Lines | Status | +|---|---|---| +| `src/lib/vapi/workflow-nodes.ts` + `workflow-edges.ts` + `workflow-types.ts` | 385 | **imported by nothing** | +| `src/lib/vapi/assessment-workflow.ts` | 649 | only `getPhaseFromNode` is imported; the 13-node graph is unused | +| `src/hooks/assessment-vapi-config.ts` | 389 | **imported by nothing** — `use-assessment-vapi.ts` inlines byte-identical copies | +| `src/hooks/use-assessment-vapi.ts` | 765 | **the only live path** | + +The 13-node question graph (greeting → chief_complaint → pain_characterization +→ functional_impact → medical_history → {red_flag_exit | movement_intro} → +flexion/extension/sidebend tests → summary → plan_generation → complete) is +**declared but never executed**. What actually runs is a single inline +assistant config with one giant system prompt +([use-assessment-vapi.ts:411](src/hooks/use-assessment-vapi.ts:411)). + +This matters for the rebuild: **the deterministic question graph the product +requires does not exist in running code.** It has to be built, not ported. + +### 4.2 Extraction happens client-side + +Six tools (`recordChiefComplaint`, `recordPainLevel`, `recordGoals`, +`recordSafetyCheck`, `recordMovementTest`, `completeAssessment`) arrive as +`function-call` messages **over the browser WebSocket** and write straight into +the Zustand store ([use-assessment-vapi.ts:473](src/hooks/use-assessment-vapi.ts:473)). +They never touch the server. Persistence is a single later POST of the whole +store to `/api/assessments/save`. + +So structured clinical extraction is currently **client-trusted**. + +### 4.3 The webhooks are orphaned + +`api/vapi/assessment-webhook` handles tools named `save_assessment_response`, +`get_movement_screen_result`, `generate_rehab_plan`, `flag_red_flag`, +`start_movement_screen` — **none of which match the six tools the live +assistant is configured with.** It stores state in a module-level `Map` with no +TTL, lost on cold start, not shared across instances. `handleEndOfCall` carries +a `// TODO: Persist to database` and deletes the entry. + +`api/vapi/webhook` reads live form state by importing `getSessionState` from +the sibling route module — cross-route singleton coupling that only works in a +single warm process. + +### 4.4 What survives a Deepgram migration + +**Portable** (depends on a 3-method seam `{say, injectContext, isConnected}`, +[form-event-bridge.ts:26](src/lib/voice/form-event-bridge.ts:26)): +`src/stores/voice-store.ts`, the assessment store family, `src/lib/voice/types.ts`, +`form-event-debouncer.ts`, and ~600 lines of provider-neutral clinical prompt text. + +**Vapi-coupled, to be replaced:** `src/hooks/use-vapi.ts`, `src/lib/vapi/*`, +both webhook routes, `src/types/vapi-webhook.ts`, the inline assistant config. + +⚠️ `src/lib/voice/form-event-bridge.ts` (class) and +`src/hooks/use-form-event-bridge.ts` (hook) are **two competing implementations +of the same logic**; only the hook is wired up. + +--- + +## 5. AI / plan generation + +`src/lib/gemini/` — 6 files, ~983 lines. + +- **No structured output.** Plain text in/out, no `responseSchema`, no JSON + mode, no function calling. `parseGeminiJson` strips ```` ```json ```` fences + ([client.ts:96](src/lib/gemini/client.ts:96)). +- **Zod validation is genuinely good** — `rawPlanStructureSchema` requires + exactly 12 weeks, sets 1–10, reps 1–100, hold 0–300. +- **Fuzzy slug matching is a correctness risk**: `findClosestSlug` + ([plan-generator.ts:35](src/lib/gemini/plan-generator.ts:35)) strips prefixes + like `kneeling-`/`standing-`/`seated-` then accepts a match on ≥2 shared words + or ≥50% word overlap. This is how a model-invented exercise name becomes a + real exercise ID. **The target architecture forbids exactly this** — the + composer must reject unknown exercises, not approximate them. +- Prompts live in three places: `prompts.ts`, inline in `plans/chat/route.ts:91`, + inline in `assessments/from-text/route.ts`. +- Three plan-generation entry points, one of which (`assessments/save`) inserts + the plan with **`status: 'approved'` without any clinician review** — directly + contrary to the product boundary in [01-product-definition.md](./01-product-definition.md). + +--- + +## 6. Tests + +21 files, ~208 assertions. Vitest + jsdom. + +**Covered:** API helpers (`validation` 27, `response` 24, `errors` 22, `auth` 16), +session lib, four Zustand stores, PT data shapes. + +**Not covered:** every `src/app/api/**/route.ts` (zero route-handler tests), +all Gemini code including fuzzy slug matching and progression validation, all +Vapi hooks and webhooks, `form-engine.ts` beyond one function (2 tests for +3,036 lines), all pages, `patient-access.ts`. + +No E2E. `vitest.config.ts:17` excludes an `e2e/` directory that does not exist. +`.playwright-mcp/` sits unused at the repo root. + +--- + +## 7. Vision — blast radius + +`src/lib/vision/` is 12 files / 3,036 lines, dominated by `form-engine.ts` +(1,286 lines). It has **only four importers**: `use-pose-detection.ts`, +`exercise-camera.tsx`, `feedback-overlay.tsx`, and one test. + +Shelving it is a small cut at the import boundary but a wider *data* cut: +`exercise-store`'s form score / rep count / phase go unpopulated, which starves +`use-form-event-bridge` → `injectContext` (live voice form coaching goes +silent), `POST /api/session-state`, and `sessions.overall_form_score`. + +⚠️ `src/components/motion/` is **not vision** — it is 5 files of framer-motion +animation wrappers imported by 16 files including the entire landing page. Do +not confuse the two during cleanup. + +--- + +## 8. Environment variables + +Declared **required** in `src/lib/env.ts` but **referenced by zero source files**: +`NEON_API_KEY`, `VAPI_PRIVATE_KEY`, `VAPI_WEBHOOK_SECRET`. + +Missing from `.env.example`: `NEON_AUTH_BASE_URL`. +Missing from **both** `env.ts` and `.env.example`, read raw from `process.env`: +`NEXT_PUBLIC_VAPI_ASSISTANT_ID` ([use-vapi.ts:245](src/hooks/use-vapi.ts:245)). + +Two escape hatches blank the entire validated config: `SKIP_ENV_VALIDATION=true`, +and `npm_lifecycle_event === 'lint'` ([env.ts:119](src/lib/env.ts:119)). + +--- + +## 9. Open questions carried into the rebuild + +1. Does the deployed database match `src/db/schema/` or `003_mock_data.sql`? + They disagree. **Resolve before any migration is written.** +2. Do the `auth.uid()` RLS policies exist in the live Neon database, and do they + evaluate? Code analysis says they cannot. +3. Does Neon Auth's user object carry a `role` claim in this beta? If not, PT + authorization has never functioned outside demo mode. +4. Has `NEXT_PUBLIC_DEMO_MODE=true` ever been deployed to a public URL? If so, + the `x-demo-role` header exposure was live, and that needs assessing + separately from this rebuild. + +--- + +*Sources: full-tree inspection, February 2026. Supersedes +`docs/redesign/current-state.md`, which under-reported the auth findings.* diff --git a/docs/architecture/03-data-architecture.md b/docs/architecture/03-data-architecture.md new file mode 100644 index 0000000..4a9e9b7 --- /dev/null +++ b/docs/architecture/03-data-architecture.md @@ -0,0 +1,293 @@ +# 03 — Data Architecture + +> **Decisions:** Supabase Postgres in **`ca-central-1`**, multi-tenancy via +> `organization_id` + **forced RLS** (ADR-005), Drizzle with a **two-client +> boundary** and `drizzle-kit push` banned outright (ADR-006). +> +> Supabase facts verified 2026-08-02. Items marked **⚠️** are undocumented by +> Supabase or unconfirmed, and are load-bearing. + +--- + +## 1. What we are replacing + +From [02-current-state.md](./02-current-state.md), the current data layer has: + +- **No tenancy column on any table.** Multi-clinic is not degraded, it is absent. +- **RLS policies that are inert.** `001_complete_schema.sql:561-655` uses + `auth.uid()` — a *Supabase* idiom — against the `neon-http` driver. They parse + and do nothing. Treat the database as having no row-level protection today. +- **Zero Postgres enums.** Fourteen tables, all status/role/type columns as + `text` + check constraints. +- **JSONB that is load-bearing and unvalidated.** Plans, assessment results, and + session metrics live in JSONB with no schema enforcement at either boundary. +- **Two competing migration systems, already drifted.** `db:push`/`db:generate` + vs `db:migrate`/`db:seed`/`db:mock`; `004` was never folded into `001`; + `003_mock_data.sql` inserts into tables and columns that do not exist and + **cannot run**. + +Every one of these is addressed below. None of it is migrated — the schema is +rewritten. + +--- + +## 2. Platform: Supabase, `ca-central-1` + +Region is fixed by [ADR-011](./09-decision-log.md) (BC market). What lands +in-region and what does not: + +| Component | Region | Note | +|---|---|---| +| Postgres | ✅ `ca-central-1` | | +| Storage | ✅ `ca-central-1` | | +| Auth | ✅ `ca-central-1` | | +| **Platform logs** | ❌ **out-of-region** | ClickHouse/BigQuery; location unstated by Supabase. **The single biggest residency gap.** | +| Edge Functions | ⚠️ **global by default** | Must be pinned with the `x-region` header — and *verify* the pin, don't assume it | +| Realtime | ⚠️ **undocumented** | Treat as out-of-region until Supabase confirms otherwise | + +**Design consequence:** assume anything that reaches platform logs has left +Canada. That means **no PHI in log lines, error messages, or exception payloads +that Supabase can see** — including Postgres `RAISE NOTICE`, constraint violation +messages that echo column values, and unhandled errors from Edge Functions. This +is the same class of discipline as the no-PHI telemetry contract in +[06 §3](./06-ai-pipelines.md), and it should be enforced the same way: a +structured logger that takes IDs and codes, never free text. + +### Cost floor + +**⚠️ Supabase's HIPAA posture requires the Team plan ($599/mo) plus an +unpublished-price add-on plus PITR ($100/mo) — a $760–810/mo floor.** +Self-hosting Supabase is **explicitly not HIPAA-capable.** + +For a BC deployment HIPAA is not the operative regime, but the same tier gates +the compliance controls we want (PITR, longer log retention, SOC 2 reporting), so +budget for it. And note again: **Supabase publishes zero PIPEDA representation** +— a legal-review item, not an engineering one. + +### Keys and signing — get this right on day one + +Start with **asymmetric ES256 JWT signing keys** and the new +`sb_publishable_` / `sb_secret_` API key format. The legacy symmetric JWT secret +and `anon`/`service_role` key format **deprecate at the end of 2026**. Migrating +signing keys later means reissuing every session; doing it now costs nothing. + +**No CMK/BYOK is available.** If customer-managed keys become a clinic +requirement, this is a platform-level blocker with no workaround. + +--- + +## 3. Multi-tenancy + +Shared database, `organization_id` on every domain table, **forced RLS**. +Separate databases per clinic were rejected: at 1–2 pilot clinics and a target of +tens, the operational cost dominates the isolation benefit, and a shared schema +keeps one migration path. + +That trade puts the entire isolation guarantee on RLS correctness. So: + +1. **`alter table … force row level security`** on every domain table. Plain + `enable` does not apply policies to the table owner, and migrations run as the + owner. +2. **`organization_id` is `not null` on every domain table**, including join and + audit tables. No nullable tenancy, ever — a null tenant is a policy hole. +3. **Every policy is `to authenticated`.** Never `to public`. A `public` policy + is evaluated for every role, including `anon`. +4. **Tenancy is derived from the JWT, never from a request parameter.** A + `?organizationId=` in a route handler is a vulnerability, not an API. + +### The anonymous-user trap + +**Supabase anonymous users receive the `authenticated` Postgres role, not +`anon`.** This surprises people, and it is exactly the kind of thing that turns +a correct-looking policy into a leak. + +The intake flow *needs* anonymous sessions: a patient follows an SMS episode link +and gets a PHI-free pending session before OTP verification +([01 stage B](./01-product-definition.md)). Those users are `authenticated` at +the Postgres level. + +**Therefore every PHI-touching policy carries a restrictive +`is_anonymous is false` gate.** Not as an extra condition on the permissive +policy — as a separate `as restrictive` policy, so it cannot be forgotten on a +later permissive policy added to the same table. + +```sql +create policy "phi_requires_verified_identity" + on public.episodes + as restrictive + to authenticated + using ((select auth.jwt() ->> 'is_anonymous')::boolean is false); +``` + +### RLS performance — four rules with measured impact + +These are not micro-optimizations. The numbers are from Supabase's own +benchmarks and the differences are three to four orders of magnitude. + +| Rule | Effect | +|---|---| +| Wrap `auth.uid()` / `auth.jwt()` in `(select …)` so it evaluates once per query, not once per row | **11,000ms → 10ms** | +| Index the tenancy column (and every column named in a policy) | **171ms → <0.1ms** | +| Use a `security definer` function for cross-table membership checks instead of an inline join | **178,000ms → 12ms** | +| Always specify `to authenticated` | avoids evaluating the policy for every role | + +The `security definer` helper is the load-bearing one — "is this user a member of +this organization?" appears in nearly every policy, and inlining that join into +each one is what produces the 178-second case. + +--- + +## 4. The Drizzle boundary — the most dangerous part of this stack + +> **Drizzle bypasses RLS by default.** It connects as the database owner. Every +> query you write is a full-table query unless you do something about it. + +This is not hypothetical: it is precisely how the current repo ended up with +policies that look protective and protect nothing. + +The fix is the community `set local role` + `set_config` transaction pattern — +**which Supabase does not document**, so it needs to live in this repo as a +deliberate, tested, reviewed boundary rather than as folklore: + +```ts +// Conceptual shape. Wrap every request-scoped query. +await db.transaction(async (tx) => { + await tx.execute(sql`select set_config('request.jwt.claims', ${claims}, true)`); + await tx.execute(sql`set local role authenticated`); + return fn(tx); // policies now apply +}); +``` + +### Two clients, enforced by lint + +| Client | Role | Use | +|---|---|---| +| `db.rls` | `authenticated`, claims set per transaction | **Everything request-scoped.** The default. | +| `db.admin` | owner, bypasses RLS | Migrations, the durable worker's system tasks, explicitly-reviewed jobs | + +**An ESLint rule forbids importing `db.admin` outside an allowlisted directory.** +Same pattern as the `mip_opt_out` URL builder in +[05 §6](./05-voice-pipeline.md) and the telemetry allowlist in +[06 §3](./06-ai-pipelines.md): a narrow, testable choke point standing in for a +policy that would otherwise depend on everyone remembering. + +Add a test that runs a representative query through `db.rls` as tenant A and +asserts zero rows from tenant B. **Per table.** RLS regressions are silent +otherwise. + +### 🔴 `drizzle-kit push` is banned + +**`drizzle-kit push` silently skips RLS policy SQL.** It will report success and +leave your tables unprotected. + +Remove `db:push` from `package.json`. The only path is `drizzle-kit generate` → +review the generated SQL → `drizzle-kit migrate`. Policies are written by hand in +migration files and reviewed like application code, because that is what they +are. + +--- + +## 5. Schema principles + +Rewritten from scratch, not migrated. The rules that differ from the current +schema: + +**Postgres enums for every closed set.** `role`, `episode_status`, +`plan_version_status`, `attention_tier`, `observation_type`, and so on. The +current schema has zero enums and fourteen tables of `text` + check constraints, +which gives no type safety at the Drizzle boundary and makes every status +comparison a string comparison. + +**JSONB is for genuinely open payloads only, and is always Zod-validated at both +boundaries.** Today plans, assessment results, and session metrics are unvalidated +JSONB. A clinical plan is *structured* — it gets real columns and real foreign +keys. If a thing has a schema, it gets a table. + +**Exercise references are foreign keys, never strings.** This is what kills +`findClosestSlug` ([06 §1](./06-ai-pipelines.md)) at the database level: a +model-invented exercise cannot be stored, because the FK will not resolve. + +**Append-only where the clinical record demands it.** Plan versions are new rows, +never updates. `AuditEvent` is insert-only with no update or delete policy at +all — not even for the owner. Corrections are new events referencing the prior +one. + +**Every domain table carries** `organization_id not null`, `created_at`, +`updated_at`, and a `created_by` that resolves to an identity. + +### Governance tables + +`GenerationJob`, `ModelRun`, `AIArtifact`, `SourceReference`, `PromptVersion`, +`EvaluationResult`, `AuditEvent` — defined in +[06-ai-pipelines.md §1](./06-ai-pipelines.md). They live in Postgres and are the +source of truth; Langfuse is a lens over them, never the record. + +--- + +## 6. Storage + +Patient-facing media and clinician uploads go to Supabase Storage in +`ca-central-1`, in **private buckets only**. Access is via signed URLs. + +> **⚠️ Signed Storage URLs cannot be revoked.** Not by key rotation, not by +> sign-out, not by deleting the object's ACL. Once issued, the URL works until it +> expires. + +Therefore: **TTLs of 60–300 seconds**, issued per-view, from a route handler that +has already checked authorization. Never embed a signed URL in a page that is +cached, emailed, or logged. A five-minute window on a URL that leaks is a +five-minute exposure; a one-hour window is a one-hour exposure with no way to +close it. + +**Raw voice audio is not stored** — see [05](./05-voice-pipeline.md). The +transcript and the structured extraction are the record. + +--- + +## 7. Migration from Neon + +Neon → Supabase Postgres is a documented path (`pg_dump`/`pg_restore`, or +Supabase's migration tooling). It is the easy half. + +**Neon Auth (Stack Auth) → Supabase Auth has no official path.** See +[04-auth-access-control.md](./04-auth-access-control.md) — the recommendation is +not to migrate credentials at all. + +Given that the schema is being rewritten and the current database holds hackathon +demo data plus mock rows, **there is no production data to migrate.** The +"migration" is: stand up a new Supabase project, run the new migration set, seed +the exercise library from `scripts/generate-seed-sql.js` output, and re-invite +staff. Treat the Neon database as disposable. + +--- + +## 8. Connection management + +`@neondatabase/serverless` (HTTP driver) is dropped. It supports **no pooling and +no transactions** — and §4's entire RLS pattern *is* a transaction, so the +current driver cannot express it even in principle. + +Supabase connection modes: + +| Mode | Use | +|---|---| +| **Session mode** (port 5432, direct or Supavisor) | Route handlers and the worker. Required — `set local role` needs session semantics. | +| Transaction mode (port 6543) | Only where prepared statements are disabled and no session state is needed. **Not compatible with the `set local role` pattern.** | + +Serverless route handlers on session-mode pooling need deliberate connection +limits. The durable worker holds long-lived connections and should be sized +separately. + +--- + +## 9. Open items + +| # | Item | Owner | +|---|---|---| +| 1 | **Supabase has no PIPEDA representation.** Clear with counsel before real patient data. | Legal — **blocking for pilot** | +| 2 | Confirm Realtime residency; if it cannot be confirmed in-region, do not use it for PHI-bearing channels | Supabase support | +| 3 | Verify the Edge Functions `x-region` pin actually holds — test it, don't trust the header | Engineering | +| 4 | Confirm what Supabase platform logs capture, and where they live | Supabase support | +| 5 | Team plan + add-on pricing for the compliance tier (add-on price unpublished) | Commercial | +| 6 | No CMK/BYOK — confirm no clinic requires customer-managed keys | Product | +| 7 | Write the per-table cross-tenant RLS test harness **before** the first policy ships | Engineering | diff --git a/docs/architecture/04-auth-access-control.md b/docs/architecture/04-auth-access-control.md new file mode 100644 index 0000000..2afff12 --- /dev/null +++ b/docs/architecture/04-auth-access-control.md @@ -0,0 +1,215 @@ +# 04 — Authentication & Access Control + +> **Decision (revised 2026-08-02): use Supabase Auth.** This reverses the earlier +> "Postgres + Storage, keep our own auth" call. Tracked as **ADR-004**. +> +> The reversal is defensible and I think correct — but it buys native RLS +> integration at the cost of four specific constraints that have to be designed +> around rather than discovered. They are §4. + +--- + +## 1. What we are replacing — and why none of it survives + +[02-current-state.md §2](./02-current-state.md) found the following. This is not +a list of bugs to fix; it is the reason the auth layer is rewritten. + +| Finding | Location | +|---|---| +| `requireAuth()` reads the **client-controlled `x-demo-role` header** and returns a hardcoded PT identity | `src/lib/auth/server.ts:29` | +| Every PT page sends that header from the browser | — | +| `.env.example:73` ships `NEXT_PUBLIC_DEMO_MODE=true` | — | +| `src/proxy.ts:7` **disables page auth entirely** in demo mode | — | +| Role is **never read from `profiles.role`** — it is read off the Neon Auth user object, which carries no `role` claim. PT authorization is likely non-functional outside demo mode. | `src/lib/api/auth.ts:76` | +| **7 routes read or write patient data with no auth at all** — `api/session-state` (GET returns *any* session's live state), `api/messages`, `api/messages/[userId]`, `api/messages/[userId]/read`, `api/vapi/webhook` (unsigned), `api/vapi/assessment-webhook` (unsigned) | — | +| `VAPI_WEBHOOK_SECRET` declared **required**, referenced by **zero** source files | `src/lib/env.ts:37` | +| Missing ownership checks — `GET /api/plans/[planId]` (no role, no ownership), `PATCH /api/plans/[planId]`, `GET /api/pt/clients/[id]` (**never checks `patient.ptId === user.id`**) | — | +| `/onboard` writes identity to **localStorage** | `src/app/onboard/page.tsx:19` | +| `/assessment/*`, `/onboard`, and **all of `/api/*`** are absent from the middleware matcher | `src/proxy.ts` | +| RLS policies use `auth.uid()` against the `neon-http` driver → **inert** | `001_complete_schema.sql:561-655` | + +Two structural lessons carried into the design: + +1. **Demo mode was a backdoor, not a feature flag.** There is no + `NEXT_PUBLIC_DEMO_MODE` in the rebuild, and no code path where an environment + variable weakens authentication. Demo data is *seeded data under real auth*. +2. **Middleware was treated as the auth layer and covered a third of the app.** + In the rebuild, `src/proxy.ts` is a redirect convenience only. + **Authorization is enforced at the data layer** — see §5. + +--- + +## 2. Why Supabase Auth + +The earlier call was to keep first-party auth (porting `rehabifyy`'s HMAC +episode links, phone OTP, episode grants, and staff 2FA). Reversed because: + +- **`auth.uid()` and `auth.jwt()` work natively in RLS policies.** This is the + whole ballgame. [03 §3](./03-data-architecture.md) puts the entire tenant + isolation guarantee on RLS; writing policies against a first-party session + table means threading claims into Postgres by hand for every request, and the + failure mode is silent. Native integration removes a category of bug. +- Phone OTP, anonymous sign-ins, and TOTP MFA/AAL2 — the four primitives the + intake flow actually needs — are all first-party. +- One less system to own, in a codebase whose current auth is the single worst + part of it. + +**What it costs:** there is no migration path *off* Supabase Auth later, and four +non-configurable behaviors constrain the design (§4). Accept both explicitly. + +--- + +## 3. Identity model + +Two populations with genuinely different lifecycles. + +### Clinic staff — physiotherapists and administrators + +Email + password or SSO, **TOTP MFA required at AAL2** for any PHI access. +Membership in an organization is a row in `organization_members`, not a claim +the user controls. + +**Role is read from the database, never from the user object.** The current +repo's central failure was reading role off an identity provider that never +issued one. Roles live in `organization_members.role` (a Postgres enum), and +reach policies via a **custom access token auth hook** that stamps +`organization_id` and `role` into the JWT at issue time. + +### Patients — episode-scoped, phone-verified + +Per [01 stage B](./01-product-definition.md): + +```text +expiring SMS episode link + → anonymous session, PHI-free pending state + → phone OTP to the number already in the clinic's record + → episode-scoped grant +``` + +**Before verification the patient sees no identity and no clinical +information.** The anonymous session exists only to hold the pending state. + +Access is scoped to **an episode**, not to an account. A patient with two +episodes at two clinics has two grants. Grants expire. This is the model +`rehabifyy` already proved out, and it survives the switch to Supabase Auth +intact — it is a data model, not an auth implementation. + +--- + +## 4. The four constraints — design around these, don't discover them + +### 4a. 🔴 Anonymous users get the `authenticated` role + +Not `anon`. A pending patient session is `authenticated` at the Postgres level, +so **every PHI policy needs a restrictive `is_anonymous is false` gate**, written +as a separate `as restrictive` policy so a later permissive policy on the same +table cannot bypass it. Full treatment in +[03 §3](./03-data-architecture.md). + +This is the single most likely source of a data leak in this design. Test it +directly: an anonymous session must return zero rows from every PHI table. + +### 4b. 🔴 MFA verify is rate-limited to 15/hour **per IP** + +Non-configurable. **A clinic behind NAT is one IP.** Five physiotherapists each +retrying a TOTP code twice on a Monday morning will exhaust it, and the failure +looks like "MFA is broken" rather than "you are rate limited." + +Mitigations: generous TOTP time-window tolerance, long-lived staff sessions so +re-verification is rare, clear error copy that names the real cause, and a +documented recovery path for the clinic. **Raise the limit with Supabase before +the pilot** — this will happen, not might. + +### 4c. 🟠 Anonymous sign-in is 30/hour per IP + +Also non-configurable, also a clinic-NAT problem — and it has a specific +implication: **anonymous sign-in must be client-initiated, never +server-proxied.** If our server creates the anonymous session, every patient in +the world shares our server's IP and we exhaust the limit globally at 30/hour. + +### 4d. 🟠 Custom claims are stale until token refresh + +Up to one hour. So **revoking a grant does not take effect immediately** if the +check depends only on a JWT claim. + +Therefore: claims carry `organization_id` and `role` for *policy efficiency*, but +**episode grant validity is checked against a live table**, not a claim. Anything +that must revoke promptly is a row, not a claim. Related: **Realtime +authorization is cached for the connection's lifetime** — a revoked user keeps an +open channel until they reconnect. Do not put PHI on Realtime channels without +accounting for this (and see the unresolved Realtime residency question in +[03 §2](./03-data-architecture.md)). + +### Also noted + +**Passkeys are explicitly experimental**, anonymous users cannot enroll, and +AAL2 status is undocumented. Not in the pilot. + +--- + +## 5. Enforcement — where authorization actually happens + +Three layers, in order of trust. **Only the innermost is load-bearing.** + +| Layer | Purpose | Trusted? | +|---|---|---| +| `src/proxy.ts` (middleware) | Redirect unauthenticated users to sign-in | ❌ UX only | +| Route handler | Validate input, check the action makes sense, load context | ⚠️ Defense in depth | +| **Postgres RLS via `db.rls`** | **The actual guarantee** | ✅ | + +The current repo inverted this: middleware was the enforcement point, it covered +a third of the routes, and RLS was inert. Under the rebuild, a route handler that +forgets an ownership check returns zero rows instead of another patient's data. + +Route handlers still perform explicit ownership and role checks — the ones missing +today on `GET /api/plans/[planId]`, `PATCH /api/plans/[planId]`, +`GET /api/pt/clients/[id]`, and `getTargetPatientId`. Defense in depth means both, +not either. + +**Every route handler gets a test.** The current repo has ~21 test files and +**zero route-handler tests** ([02 §6](./02-current-state.md)). The minimum bar +per handler: unauthenticated → 401; wrong tenant → 404 or empty; wrong role → +403; anonymous session against PHI → empty. + +--- + +## 6. Webhooks + +Both current webhooks (`api/vapi/webhook`, `api/vapi/assessment-webhook`) are +**unauthenticated and unsigned**, accept patient data, and are deleted with Vapi +([05 §9](./05-voice-pipeline.md)). + +The rule for any replacement: **signature verification before body parsing**, +constant-time comparison, timestamp window to reject replays, and the secret +actually referenced by code. `VAPI_WEBHOOK_SECRET` was declared required and used +by nothing — a lint check should catch a declared-but-unreferenced secret, since +that pattern reliably means the verification was never written. + +--- + +## 7. Keys, sessions, audit + +**Asymmetric ES256 signing keys** and `sb_publishable_` / `sb_secret_` API keys +from day one — legacy formats deprecate end of 2026, and migrating signing keys +later reissues every session. See [03 §2](./03-data-architecture.md). + +**`sb_secret_` never reaches the browser.** Anything holding it is server-only, +in the `db.admin` allowlist directory. + +**`AuditEvent` is insert-only** with no update or delete policy for any role. +Every authentication event, grant issue and revocation, PHI read by staff, and +plan-version approval writes one. Corrections are new events referencing the +prior event — the clinical record does not get rewritten. + +--- + +## 8. Open items + +| # | Item | Blocks | +|---|---|---| +| 1 | **Raise MFA verify rate limit with Supabase** (15/hr/IP vs clinic NAT) | Pilot — this *will* fire | +| 2 | Confirm AAL2 enforcement semantics for the staff flow | MFA design | +| 3 | Confirm Realtime authorization-cache behavior on grant revocation | Any PHI on Realtime | +| 4 | Decide the staff recovery path when MFA rate-limits a whole clinic | Pilot runbook | +| 5 | Confirm anonymous sign-in from the client meets the 30/hr/IP limit under real clinic conditions | Intake flow | +| 6 | Write the anonymous-session-sees-nothing test **before** the first PHI table ships | Engineering | diff --git a/docs/architecture/05-voice-pipeline.md b/docs/architecture/05-voice-pipeline.md new file mode 100644 index 0000000..d1b1fb2 --- /dev/null +++ b/docs/architecture/05-voice-pipeline.md @@ -0,0 +1,451 @@ +# 05 — Voice Pipeline + +> **Decision:** composed **Deepgram STT → GPT-5.6 → Deepgram Aura TTS**, orchestrated +> by Rehabify. Not Vapi. Not Deepgram's end-to-end Voice Agent API. +> +> Supersedes [ADR-001](./09-decision-log.md) (Gemini 2.0 Flash Live) and removes +> the Vapi dependency entirely. Tracked as **ADR-007**. +> +> All Deepgram facts verified against `developers.deepgram.com` on **2026-08-02**. +> Claims that could not be verified are marked **⚠️ UNVERIFIED** and must be +> confirmed with Deepgram sales before infrastructure is committed. + +--- + +## 1. Why composed, not managed + +Vapi (current) and Deepgram Voice Agent (the obvious alternative) both bundle +STT + LLM + TTS behind one socket and run the turn loop for you. Rehabify cannot +use either, for a product reason rather than a cost reason. + +Intake is **a controlled questionnaire wearing a conversation's clothes**. The +question graph is clinician-approved; the LLM is allowed to *phrase* an approved +question and *select* among approved follow-ups, and nothing else +([01-product-definition.md](./01-product-definition.md)). A managed agent owns +the turn loop, which means it owns the decision about what to say next. That is +precisely the authority we must not delegate. + +Composing also gives us three things we need independently: + +- The transcript and the structured extraction land **server-side**, inside the + PHI boundary, before anything is written. The current implementation extracts + on the client and writes to Zustand — see [02-current-state.md §4](./02-current-state.md). +- `mip_opt_out=true` (§6) can be enforced in one server-side URL builder rather + than trusted to a browser. +- The question graph transition is a **deterministic** step between STT and the + LLM, not a prompt instruction the model may ignore. + +Cost is a secondary benefit, not the argument. See §7. + +--- + +## 2. Topology + +```text +browser + mic → AudioWorklet → linear16 @ 16 kHz + │ + └──WSS──► Rehabify voice gateway ◄── the PHI boundary starts here + │ + ├──WSS──► Deepgram /v1/listen (nova-3-medical, mip_opt_out=true) + │ └─ turn commit (§4) + │ + ├── deterministic question-graph transition ← no LLM + ├── GPT-5.6 Luna: phrase the allowed question / extract structured answer + ├── Zod validation + clinical-rule checks + │ + ├── static approved-question audio (cache hit, ~90% of turns) + └──WSS──► Deepgram /v1/speak (aura-2, mip_opt_out=true) + │ + ◄──WSS─────────┘ PCM frames + the visible text that produced them +``` + +**Browser → Deepgram direct is technically supported and we are not doing it.** +Deepgram issues 30-second ephemeral JWTs from `POST /v1/auth/grant` that only +need to be valid at handshake, and the browser `WebSocket` header limitation is +worked around by passing the token as `Sec-WebSocket-Protocol`. It would work. +It is rejected because `mip_opt_out=true` would live in a client-constructed URL +— a stale or tampered client silently enters PHI into a training corpus — and +because we need the audio and transcript server-side regardless. The extra hop +costs tens of milliseconds against a turn budget measured in hundreds. + +We still use `/v1/auth/grant` **server-side**, so the long-lived API key never +sits in the outbound request path and per-session revocation is clean. + +--- + +## 3. The model choice — and the tradeoff we cannot avoid + +Deepgram shipped `flux-general-en` since Nova-3: a turn-based conversational +model on `/v2/listen` that emits `EndOfTurn` natively and replaces the entire +`endpointing` + `utterance_end_ms` + `speech_final` heuristic stack. It is +better at exactly the thing that is hard here. + +**Flux has no medical variant.** `nova-3-medical` is `/v1/listen` only. You +cannot have both. + +| | `nova-3-medical` (`/v1/listen`) | `flux-general-en` (`/v2/listen`) | +|---|---|---| +| Medical vocabulary | 11% WER reduction vs Nova-3 general streaming; 2.7× keyword recall | general model | +| Turn detection | heuristic; you implement it (§4) | model-native `EndOfTurn`, `eot_threshold` | +| Smart formatting | ✅ | ❌ (numerals only) | +| Keyterms | ✅, fixed per connection | ✅, swappable mid-stream via `Configure` | +| Batch re-processing | ✅ | ❌ streaming-only | +| Self-hosted | ⚠️ "coming soon" as of the streaming announcement | ✅ | + +**Decision: `nova-3-medical`.** The reasoning is asymmetric-risk, not accuracy +score. Our answer space per question node is *narrow and known* — the question +graph tells us whether we expect a number, a date, a body region, or a yes/no. +That means we can compensate for a turn-detection false positive (re-prompt, +or accept a partial and confirm). We cannot compensate for a mis-transcribed +anatomy term, because we will not know it was wrong. + +This is testable and should be tested. Run both against recorded intake audio +and measure **term accuracy vs premature-cutoff rate**. If cutoffs prove worse +in practice than vocabulary errors, Flux at Deepgram's own documented +"High-Reliability Mode" (`eot_threshold=0.85`, `eot_timeout_ms=8000`, no eager) +is the fallback — that profile is explicitly recommended for medical settings +and suits a patient who pauses to think. + +### Connection parameters + +``` +wss://api.deepgram.com/v1/listen + ?model=nova-3-medical + &encoding=linear16&sample_rate=16000&channels=1 + &interim_results=true # REQUIRED for utterance_end_ms — silent failure otherwise + &endpointing=400 + &utterance_end_ms=1500 # min 1000, max 5000 + &vad_events=true # SpeechStarted, for barge-in + &numerals=true + &smart_format=false # see below + &mip_opt_out=true # see §6 — non-negotiable + &keyterm=...&keyterm=... # repeated param, see below +``` + +**`smart_format=false` is deliberate.** Smart formatting delays finalization +while it waits for an entity to complete, which suppresses `speech_final` +mid-utterance — the documented "waits for more audio when speaking a phone +number" behavior. Since the question graph already tells us the expected answer +type, parsing `"seven out of ten"` → `7` and `"since March"` → a date is more +robust in our own normalization layer, and it decouples formatting from turn +detection. + +### Keyterm prompting + +Nova-3 and Flux only (Nova-2 and older use the legacy `keywords` feature). The +limit is **500 tokens across all keyterms, not 500 terms**; Deepgram's own +guidance is to stay at 20–50 well-chosen terms. + +Three ways to fail silently, all of which we must unit-test: + +| Wrong | Right | +|---|---| +| `keyterm=a,b,c` | `keyterm=a&keyterm=b&keyterm=c` | +| `keyterm=rotator cuff` | `keyterm=rotator%20cuff` | +| `keyterm=patella:0.15` (that's `keywords` syntax) | `keyterm=patella` — **weights are not supported** | + +**None of these return an error.** The API accepts the value as one literal +keyterm and boosts nothing. A single unit test on the URL builder is the whole +mitigation, and it is required. + +Case is preserved and influences output: lowercase common nouns (`patellofemoral`), +capitalize proper nouns. Because Nova-3 cannot swap keyterms mid-stream, we ship +one pruned union list for the knee pathway rather than per-node scoping. (Per-node +scoping is a reason to revisit Flux later.) + +--- + +## 4. Turn detection — implement it exactly + +This is the part most likely to be built wrong, so it is specified rather than +described. Deepgram's docs are explicit: **"Do not use `speech_final: true` +alone to capture full transcripts."** + +- **`is_final`** — that *audio segment* is frozen. A long answer produces + **several** `is_final` messages before the turn ends. You must accumulate them. +- **`speech_final`** — VAD saw `endpointing` ms of silence. This is the + *utterance* boundary. Commit the buffer. +- **`UtteranceEnd`** — a safety net computed from word timings, not acoustics. + It exists because background noise can keep the VAD triggered and suppress + `speech_final` entirely. Clinic rooms have background noise. +- **`SpeechStarted`** — start of speech, for barge-in. + +```ts +let buffer = ""; +let committedRecently = false; + +onResults(msg => { + if (msg.is_final) buffer += msg.transcript; + if (msg.speech_final && buffer) { + commitTurn(buffer); + buffer = ""; + committedRecently = true; + } +}); + +onUtteranceEnd(msg => { + // Documented: -1 means the result was already finalized before the + // utterance_end_ms condition was met. Processing it duplicates the turn. + if (msg.last_word_end === -1) return; + if (!committedRecently && buffer) { + commitTurn(buffer); + buffer = ""; + } + committedRecently = false; +}); +``` + +**Known false-trigger source:** `UtteranceEnd` fires on a word-timing gap even +when the patient is still speaking. Deepgram's own worked example shows it +firing mid-thought and concludes it "can make it less ideal for voice agent +applications where you want to wait for truly complete utterances." For a +patient recalling when their knee started hurting, this will happen. Mitigation +is client-side gap logic plus — critically — **the question graph's expected +answer type**: if we expect a number and got `"it started maybe"`, that is a +schema validation failure, not a turn, and we re-prompt rather than advance. + +--- + +## 5. Connection lifecycle — the failure modes + +| Behavior | Consequence for us | +|---|---| +| **10s no-data timeout** (`NET-0001`). Send `KeepAlive` every 3–5s during silence. ⚠️ Docs conflict: Keep Alive page says 10s, the Flux comparison table says 12s. 3–5s intervals make it moot. | An 8–12 min intake has long silences while the patient thinks. Without `KeepAlive` the socket dies mid-question. | +| **`KeepAlive` must be a TEXT frame.** Sent as binary it is "handled incorrectly" and causes audio processing to "choke or hiccup." No server ACK. | Classic silent degradation. Assert frame type in the transport layer. | +| **Audio must start within 10s of opening.** | We greet with TTS before the patient speaks. **Open the STT socket late**, or `KeepAlive` from the moment it opens. | +| **Max send rate 1.25× realtime.** | Bounds reconnect catch-up. A 30s buffered gap takes 24s to drain. | +| **Timestamps reset to 00:00:00 on every new connection.** | The clinical record needs per-answer timing for provenance ([stage D](./01-product-definition.md)). Track a session-level offset and add it. | +| **Audio during reconnect is lost** unless buffered client-side. | On reconnect, discard in-flight turn state and **re-ask the current question**. Do not guess at a partial answer. | +| **TTS WebSocket: hard 60-minute cap.** | Per-session sockets are fine. A long-lived shared TTS socket is not. | +| **TTS `Flush` capped at 20 per 60s.** | Flush **per turn**, never per sentence. | +| `diarize` is deprecated, and halves EU/AU streaming concurrency. | Not needed — single-speaker intake. | + +Close cleanly with `{"type":"CloseStream"}` so the server flushes remaining +audio and sends summary metadata. + +Close codes worth handling distinctly: `1008 DATA-0000` (undecodable audio — +usually wrong `encoding`/`sample_rate`, or a control message sent as binary), +`1011 NET-0001` (client silent — `KeepAlive` does **not** reset this one), +`1011 NET-0002` (no-audio timeout — `KeepAlive` **does** reset this one). + +### Concurrency ceiling + +Limits are **per project, not per API key**, and Deepgram's ToS explicitly +forbids splitting traffic across projects to evade them. + +| | PAYG | Growth (NA) | +|---|---|---| +| Nova-3 / Flux streaming | 150 | 225 | +| **TTS streaming (Aura-2)** | **45** | **60** | + +**TTS is the real ceiling, not STT.** One STT + one TTS socket per session caps +us at ~45 concurrent intakes on PAYG. §7 removes TTS from the concurrency path +entirely. + +--- + +## 6. Compliance — the residency problem + +This is the section that gates the whole design. + +| | Status | +|---|---| +| BAA | ✅ but **Enterprise tier only** — "for Enterprise customers handling electronic Protected Health Information (ePHI)" | +| SOC 2 | Type 1 and Type 2 | +| Encryption | TLS 1.3, AES-256, in flight and at rest | +| EU residency | ✅ `api.eu.deepgram.com` | +| AU residency | ✅ `api.au.deepgram.com` | +| **Canadian residency** | ❌ **No public endpoint exists.** | + +The TTS latency doc states it flatly: **"Deepgram's servers are exclusively in +the United States."** The only regional endpoints are EU and AU. + +### Model training — read this twice + +The pricing page footnote: **"Rates listed above opt in to the Model Improvement +Partnership Program."** For a self-serve account, **the default posture is +opt-in to training on your audio.** + +The opt-out is a per-request query parameter: + +> "Add `mip_opt_out=true` as a query parameter of **all** API requests that you +> want to be excluded from the Model Improvement Program. **Data from opted-out +> requests is retained only for the duration necessary to process the request.**" + +That second sentence is our zero-retention guarantee, and it is **per request**, +not per account. Consequences: + +1. It must be on **STT, TTS, and every other** Deepgram call. +2. It is enforced in **one** URL builder, server-side, with a unit test. +3. **A single request missing the flag is a PHI disclosure.** Treat the builder + as security-critical code — same review bar as the RLS boundary in + [03-data-architecture.md](./03-data-architecture.md). +4. Deepgram's published rates assume opt-in. **Get the opt-out rate in writing** + during the Enterprise negotiation; the MIP doc lists "discounted pricing for + program participants" as a benefit without publishing the delta. + +Deepgram's privacy policy was last updated **2021-10-26** and is silent on model +training. The MIP doc is the operative source. That staleness is itself worth +raising in the contract conversation. + +### Paths to Canadian residency + +**Deepgram Dedicated** — single-tenant, fully managed, "runs on AWS +infrastructure in your preferred region." AWS has `ca-central-1` (Montreal) and +`ca-west-1` (Calgary), so this is the most likely path without running our own +GPUs. **⚠️ Deepgram publishes no region list. Unconfirmed.** + +**Self-hosted** — Enterprise plan, Docker/Podman/Kubernetes in our own VPC, +**requires NVIDIA GPUs**. The license server phones home with metadata only: +"no audio, transcripts, or other identifying markers of the request content are +sent to Deepgram." Flux is available self-hosted. **⚠️ `nova-3-medical` +self-hosted was "coming soon" as of the streaming announcement and I found no +doc confirming it shipped.** If it has not, self-hosting forces the Flux +tradeoff from §3. + +### Questions for Deepgram sales — before infrastructure is provisioned + +1. Can Dedicated be provisioned in AWS `ca-central-1`, and does the BAA cover it? +2. Is `nova-3-medical` available on Dedicated and/or self-hosted **today**? +3. What rate applies with `mip_opt_out=true`, and can **zero retention be + written into the contract** rather than depending on a query parameter? +4. Where does Aura-2 TTS run for a Dedicated customer? Question text is + PHI-adjacent — a question can encode the answer to the previous one. + +> **This is a gating dependency, not a detail.** If the answer to (1) or (2) is +> no, either the medical model or Canadian residency has to give, and that is a +> decision for the clinical lead and counsel — not an engineering call. It also +> interacts with the unresolved Canada-vs-US market question in +> [01-product-definition.md](./01-product-definition.md) (ADR-011): a US market +> makes this section largely moot, since a BAA over US-hosted Deepgram is a +> well-trodden path. + +--- + +## 7. TTS — pre-render the approved questions + +**The question graph is clinician-approved and versioned. Most prompts are +static.** That is not a cost optimization, it is a direct consequence of the +product's authority boundary — and it happens to solve three problems at once. + +**Pre-render every approved question to audio at build time**, keyed by +`(question_id, prompt_version, voice, model)`. Use live TTS only for +LLM-generated clarifications and confirmations. + +| Problem | Effect of pre-rendering | +|---|---| +| TTS is ~4× the STT cost per session (§ below) | Collapses to near zero | +| TTS concurrency cap of 45 is the system ceiling | Removes TTS from the concurrency path | +| Approved wording must be exact and reviewable | The audio artifact *is* the reviewed artifact, pinned to a `prompt_version` | +| `Flush` rate limit of 20/60s | Rarely approached | + +Cost model, 10-minute session, PAYG promo rates: + +| | Rate | Session cost | +|---|---|---| +| STT, Nova-3 mono streaming | $0.0048/min | **$0.048** | +| TTS Aura-2, ~6,000 chars | $0.030/1k chars | **$0.180** | +| TTS Aura-1, ~6,000 chars | $0.0150/1k chars | $0.090 | +| | | **~$0.23/session, TTS-dominated** | + +Billing is **true per-second**. Against the current Vapi bundle at ~$0.15/min +(~$40/patient/month per [02-current-state.md](./02-current-state.md)), the +composed pipeline with pre-rendered audio is roughly two orders of magnitude +cheaper. Enterprise pricing will differ; treat these as shape, not budget. + +### Voice and API shape + +Aura-2, `aura-2-{voice}-{lang}`. Deepgram's own descriptors point at two +candidates for clinical tone: **`aura-2-harmonia-en`** ("Empathetic, Clear, +Calm, Confident") and **`aura-2-vesta-en`** ("Natural, Expressive, Patient, +Empathetic"). Pick one with the clinical lead; it is a product decision. + +**Do not use Flux TTS** (`/v2/speak`). It is Early Access and Deepgram states +"the API surface and voice catalog may change before general availability." +Not a foundation for a clinical product. + +Encoding split that shapes the client: + +> "The streaming WebSocket emits raw audio only: `linear16`, `mulaw`, or +> `alaw`. Compressed and containerized encodings (`mp3`, `opus`, `flac`, `aac`) +> are available on the **REST endpoint only**." + +So: WS gives PCM that must go through Web Audio / AudioWorklet — no +`