diff --git a/docs/architecture/00-README.md b/docs/architecture/00-README.md new file mode 100644 index 0000000..520414e --- /dev/null +++ b/docs/architecture/00-README.md @@ -0,0 +1,143 @@ +# 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 — ~9,300 LOC and 25.9 MB, with verdicts and risk | +| 08 | [Migration Plan](./08-migration-plan.md) | Three concurrent tracks, what gets ported, the non-engineering gates | +| 09 | [Decision Log](./09-decision-log.md) | Sixteen ADRs, and what they supersede | +| 10 | [Clinical Content & Evaluation](./10-clinical-content.md) | Where exercises come from, the metadata that gates rather than displays, templated plans, and the eval strategy | +| 11 | [Diagrams](./11-diagrams.md) | Where components live, one voice-intake turn, and what gets recorded where | + +**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 | +| Speech hosting | **Self-hosted in `ca-central-1`**, on cloud credits — *proposed*, gated on model availability ([ADR-013](./09-decision-log.md#adr-013)) | +| LLM | **GPT-5.6** — Luna for bounded work, Sol for clinician-facing prose. Residency is an open fork ([ADR-008](./09-decision-log.md#adr-008)) | +| Observability | **Langfuse Cloud** — no-PHI on real traffic, **full content on synthetic**, split across two projects ([08 §3a](./08-migration-plan.md)) | +| Sequencing | **Three concurrent tracks, two deadlines** — raise and first-patient are different dates ([ADR-014](./09-decision-log.md#adr-014)) | +| Vision | **Retained, untouched, out of the first slice** — rebuilt from scratch later, never ported ([ADR-003](./09-decision-log.md#adr-003), [ADR-015](./09-decision-log.md#adr-015)) | +| First slice | **Voice intake → plan generation → clinician approval**, cut thin through every layer ([ADR-015](./09-decision-log.md#adr-015)) | +| Languages | **TypeScript owns the web app and *all* database access; Python owns the AI service** — no DB credentials in Python ([ADR-016](./09-decision-log.md#adr-016)) | +| Frameworks | **No LangChain, no LangGraph** — the model is forbidden from routing, which is what they are for. Langfuse is unrelated and stays | +| Shape | Modular monolith + a durable worker — **now two services** ([ADR-016](./09-decision-log.md#adr-016)) | + +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. Buy residency, or don't send the data, or host it yourself.** +Canadian residency is unsolved or unconfirmed with every vendor in this stack, and +there are exactly three answers. **Buy it** where it is sold — Postgres, Storage, +and Auth sit in `ca-central-1`. **Don't send the data** where it is not sold and +the data need not travel — no observability vendor has a Canadian region, so the +answer is to send no PHI at all and assert that in CI. **Host it yourself** where +the data must travel and no region exists: speech is that case, and Deepgram's +containers can run in `ca-central-1` even though Deepgram's cloud cannot +([ADR-013](./09-decision-log.md#adr-013)). + +The third move is what the cloud credits are for, and it is worth naming why it +was almost missed: the first draft of these docs treated a vendor's hosted region +list as the whole menu, concluded speech had no answer, and wrote gate 2 as an +open-ended wait. **The residency question is "where does the data sit," not +"where does the vendor operate."** + +**3. A guarantee that depends on everyone remembering is not a guarantee.** +Five 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)), the +telemetry attribute allowlist test ([06 §3](./06-ai-pipelines.md)), the two +Langfuse projects with separate credentials ([08 §3a](./08-migration-plan.md)), and +the Python service having no database credentials at all +([ADR-016](./09-decision-log.md#adr-016)). Each is one function, one test, or one +absence, and each stands in for a rule that would otherwise be documentation. + +The last two are the better kind. A lint rule can be disabled and a test can be +deleted; **a credential you were never issued cannot be used.** Prefer that shape +where the choice exists. + +**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 twelve 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 today. + +**They should start today because they are cheap to run in the background, not +because they block the next thing.** All three gate *real patient data*, and the +nearer milestone runs on synthetic data ([ADR-014](./09-decision-log.md#adr-014)). +Work proceeds on three tracks that do not wait on each other. + +The largest unpriced risk in the project is not technical: **Track B0, committing a +clinical lead.** Stages 8 and 9b, gate 9, and the entire scope of +[10](./10-clinical-content.md) rest on physiotherapist hours that are not yet +promised. + +Two decisions remain explicitly **Proposed** rather than Accepted, and they are +the same question seen twice: + +- [ADR-010](./09-decision-log.md#adr-010) — `nova-3-medical` over Flux, settled by + a bake-off against recorded intake audio. +- [ADR-013](./09-decision-log.md#adr-013) — self-hosted speech in `ca-central-1`, + gated on whether streaming `nova-3-medical` is available self-hosted at all. + +If it is not, **Canadian residency and the medical speech model are mutually +exclusive**, and that is a clinical-risk decision rather than an architectural +one. Both are answered by the same Deepgram Enterprise conversation, which is why +it should start now. diff --git a/docs/architecture/01-product-definition.md b/docs/architecture/01-product-definition.md new file mode 100644 index 0000000..a6f8e20 --- /dev/null +++ b/docs/architecture/01-product-definition.md @@ -0,0 +1,244 @@ +# 01 — Product Definition + +> Adapted from `feature-architecture-plan.md` in the `rehabifyy` repository, +> with this rebuild's scope decisions applied: **computer vision is retained** +> ([ADR-003](./09-decision-log.md), reversed 2026-08-02), +> 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 + +Autonomous diagnosis, AI-observed form *correction* as a clinical claim, 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. + +> **Note on vision — updated 2026-08-02.** The vision *code* is retained in full +> ([ADR-003](./09-decision-log.md#adr-003)) — nothing is deleted or refactored. +> It is also **out of scope for the first vertical slice** +> ([ADR-015](./09-decision-log.md#adr-015)), and will be **rebuilt from scratch** +> when it returns rather than ported onto the new foundation. +> +> That resolves the tension this note previously recorded. Stage G's *"never +> claims to observe or correct form"* was in conflict with a live +> form-correction feature; with vision outside the slice there is no such feature +> in the first release, so the clause is **accurate as written**. Renegotiate it +> when vision re-enters product scope — not before the pilot. + +--- + +## 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 | Retained; billing premise discarded on ADR-011 grounds | + +**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..684eeaa --- /dev/null +++ b/docs/architecture/02-current-state.md @@ -0,0 +1,315 @@ +# 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. + +**Vision is retained in full** ([ADR-003](./09-decision-log.md)) — this section +is kept because it maps the blast radius of the *data* it produces, not because +anything is being removed. + +That data path is the reason removal was never cheap, and it is now the reason +vision has to be carried through the rebuild deliberately: `exercise-store`'s +form score / rep count / phase feed `use-form-event-bridge` → `injectContext` +(live voice form coaching), `POST /api/session-state`, and +`sessions.overall_form_score`. Every one of those consumers is rebuilt on +Supabase, so each needs a decision about where vision output lands. + +⚠️ `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..973f42f --- /dev/null +++ b/docs/architecture/03-data-architecture.md @@ -0,0 +1,307 @@ +# 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. + +> **The Python service is not in this table, and that is the point.** +> Under [ADR-016](./09-decision-log.md#adr-016) the AI service holds no database +> connection and no database credentials. It returns validated models; the +> TypeScript tier persists them. +> +> This began as a workaround — the ESLint guard above cannot run on Python — and +> ended up stronger than the design it replaces. "All database access goes through +> one boundary" stops being a rule someone can forget: the AI service cannot reach +> Postgres because it has nothing to reach it with. **Do not give the Python +> service a database client to save a round trip.** That one change deletes the +> guarantee. + +### 🔴 `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 schema-validated at +both boundaries** — Zod in the TypeScript tier, Pydantic in the AI service +([ADR-016](./09-decision-log.md#adr-016)); the two are generated from one source +so they cannot drift. 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..f859926 --- /dev/null +++ b/docs/architecture/05-voice-pipeline.md @@ -0,0 +1,469 @@ +# 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 + │ (Python / FastAPI — ADR-016) + ├──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 + ├── Pydantic validation + clinical-rule checks + │ + ├── static approved-question audio (cache hit, ~90% of turns) + ├──WSS──► Deepgram /v1/speak (aura-2, mip_opt_out=true) + │ + └──HTTPS─► Next.js API — persist the turn ◄── no DB client here + │ + ◄──WSS─────────┘ PCM frames + the visible text that produced them +``` + +**The gateway is Python and has no database credentials** +([ADR-016](./09-decision-log.md#adr-016)). It hands each validated turn to the +TypeScript tier, which writes it through `db.rls`. That is one extra hop per +turn — single-digit milliseconds within a region, against a turn budget measured +in hundreds — and it is what keeps every database write behind the single +boundary in [03 §4](./03-data-architecture.md). + +**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. (Also note the public AWS Marketplace Nova-3 Medical listing is +labelled **batch** — not usable for streaming intake.) + +> **Decided 2026-08-02 — self-hosted is now the plan, not one of two options.** +> [ADR-013](./09-decision-log.md#adr-013) selects self-hosting in `ca-central-1`, +> funded by cloud credits, and re-aims the Deepgram conversation from a residency +> *request* to an Enterprise *sales* call. Dedicated remains the fallback if the +> GPU operational burden proves unjustified. Question 2 below is now the one that +> decides the architecture. + +### 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. +> +> *Updated: the Canada-vs-US market question this note originally hedged against +> is settled — [ADR-011](./09-decision-log.md#adr-011) accepted British Columbia, +> so the "a US market makes this moot" escape hatch is closed. This section is +> load-bearing.* + +--- + +## 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 +`