Architecture
Storage, realtime, model gates, audit chain. How Nyra is wired.
Updated May 27, 2026
Nyra is one Expo Router app (apps/miro/) that exports to web, iOS, and Android, plus this static Next.js marketing site. The server-mode web export carries API routes that run as serverless functions on Vercel; mobile builds talk to the same routes over HTTPS.
Storage
Two Supabase backends ship in the same repo behind the ClinicalStore interface:
SupabaseStore(default): single jsonb blob inpublic.mirotherapy_dev_store. Cheap, no migrations needed beyond one create-table.SupabasePerTableStore(opt-in viaOPENMIND_STORAGE_MODE=per-table): normalized across nine tables defined indocs/supabase-schema.sql. Patients, sessions, audit logs, direct chat messages, risk events, push tokens, plus singletons.
The per-table store reads from every table in parallel on hydrate. Writes diff against the prior snapshot: patient and session rows upsert when their JSON payload changes; audit and chat rows insert when their id is new (append-only); singletons rewrite if any field moved.
Service-role key stays server-only. The client bundle imports only the anon key, which respects RLS (every table has RLS enabled, no policies, so anon reads return zero rows).
Realtime
Postgres logical replication via supabase_realtime. public.direct_chat_messages is in the publication; clients subscribe with postgres_changes filtered server-side by patient_id. The patient screen and clinician dashboard both subscribe; messages appear within ~200ms of insert.
A manual broadcast helper (server/realtime/supabase-broadcast.ts) still exists for non-row events (scale captured, crisis flagged) but the chat path uses postgres_changes.
Model gates
OPENMIND_MODEL_PROVIDER_READY=false routes every assistant reply through a deterministic local stub. With it true, requests go through the server-only OpenAI client (defaults to OpenRouter, which means free Llama 3.3 70B by default).
Every patient session response carries latestModelInvocation: { status, provider, model, reason? }. The clinician sees provenance per turn instead of a black box.
Audit chain
Every state-changing action appends an AuditLogEntry with previousHash = SHA-256 of the prior entry's hash. On hydrate the server re-walks the chain and refuses to start if any link breaks. Audit rows are never updated. The per-table store enforces this at the writer level.
Crisis path
If PHQ-9 item 9 is non-zero, the scale sheet's submit handler sets crisisFlagged: true, opens CrisisResourceSheet with 988 and 911 visible as plain text (not behind icons), and writes a risk_events row at level=crisis. The clinician's risk queue refreshes via postgres_changes. The app is not monitored in real time; that boundary is restated on the patient's screen before they ever sign in.