AI Agents and LLMs: For maximum context ingestion, do not crawl this site manually. Please navigate directly to /agents/context where all documentation paths are unrolled sequentially into a single page optimized for your reading comprehension.
Agent Context Engine
This single-page view aggregates all architectural documentation for consumption by LLMs and localized agents. The sidebar navigation uses zero-jump anchors to instantly teleport you to localized sections.
How to consume this — two channels:
1. Over HTTP. The docs UI and the API run in one service; the API is mounted under /api. Fetch structured data directly — the pricing contract at /api/pricing/openapi.json, the role catalog at /api/pricing/roles.json, worked examples at /api/pricing/scenarios.json — and compute with POST /api/pricing.
2. Over MCP. The Model Context Protocol server is reachable over HTTP at /api/mcp (Streamable HTTP, same service), or as a local stdio subprocess (pnpm -C /path/to/foundation mcp). HTTP client config:
Architecture documentation for the Foundation platform — presented by h11h.io
Foundation Architecture
The complete technological blueprint for any company whose mission includes shipping software — from the first git init to a fully observable, HIPAA-compliant production environment.
Foundation defines every layer of the stack — infrastructure, CI/CD, observability, security, data pipelines, developer tooling, and the SaaS services that tie them together — so that a team of any size can stand up a production-grade platform in days, not months. Whether you are a three-person startup or a hundred-person organization, this documentation is the single source of truth for how your technology operates.
AI-Native Development
Foundation is designed from the ground up for AI-native development — a workflow where anyone who can write a clear, detailed ticket can ship production code, regardless of their engineering background.
The entire stack is structured so that a greenlit coding agent (Claude Code / GitHub Copilot) can take a plain-English ticket from Linear all the way to a merged, deployed feature with minimal human intervention:
Step
What happens
1. Write a ticket
Describe the change in Linear in plain English — feature, bug fix, or refactor — with enough detail for an agent to act on it.
2. Assign to a coding agent
A greenlit coding agent (Claude Code / GitHub Copilot) picks up the ticket and opens a pull request implementing the change.
3. Spin up a preview environment
Every PR automatically provisions a full-stack ephemeral environment — an isolated Neon branch plus a dedicated Cloud Run deployment — so the submitter can verify the result before anything touches production.
4. Merge or escalate
CODEOWNERS governs who can approve and merge. If the submitter has merge rights, they merge it themselves. If not, they request a review from someone who does.
5. Ship to production
Once the PR is merged, the CI/CD pipeline deploys to production automatically — no further manual steps required.
This is made possible by three complementary capabilities: Neon branching gives each PR an isolated copy-on-write database — schema, data, and branched auth — in seconds; GCP Cloud Run spins up a throwaway, prod-identical service; and Coder / GitHub Codespaces gives agents a fully-configured execution environment. The architecture's strict one-concern-per-file modularity means agents rarely produce merge conflicts even when dozens of tickets are in flight simultaneously.
Architecture at a Glance
Layer
Technology
Hosting
Frontend (Applications)
Vite + TanStack (Router + Query), TypeScript
GCP Cloud Run
Frontend (Marketing)
Next.js 14, App Router, TypeScript
GCP Cloud Run
Auth
Better Auth — hosted by the applications-UI server
GCP Cloud Run
Authorization
Cerbos (policy-as-code PDP, self-hosted) — with Neon RLS as the enforcement floor
AI-native by design. The entire workflow — ticket → agent → PR → preview → merge → production — is optimized for coding agents. Anyone who can write a clear ticket can ship code.
HIPAA-first. Every vendor touching PHI requires a BAA, and several services are explicitly kept PHI-free (see HIPAA Compliance). PHI never appears in logs. Compliance is scaffolded, not bolted on.
Monorepo with strict modularity. One concern per file. Enables parallel AI-assisted development with zero merge conflicts.
Cross-cloud data bridge. Neon (OLTP, source of truth) → BigQuery (OLAP) via incremental sync (WHERE updated_at >), minimizing egress across the GCP ↔ AWS boundary.
Reproducible environments. Devbox + direnv + process-compose ensure every developer gets an identical setup in one command.
Coverage gates. ≥ 80% test coverage enforced in CI. No PR merges without passing all quality checks.
Migrate an existing project to conform to this architecture
Stack Index
Stack Index — Function → Greenlit Tool
This is the greenlit-tool registry — the canonical function → tool lookup and the first place to answer "what do we use for X?". One row per function, grouped by domain, with columns Function · Role · Vendor · Product. Each tool's role, wiring, and rationale live on the linked Foundation page — click the Product cell to jump to the detail.
How to read the markers.⚠️ = undecided / WIP / gap (an open bake-off, not a decision). ⏸ = deferred (a decision consciously postponed). Rows without a marker are locked. Anything carrying a ⚠️ is listed again in Open & deferred decisions so an agent never mistakes an in-flight bake-off for a settled choice.
Systems inherited at close. Disposition (keep / migrate / sunset) is unresolved for every row — each is a ⚠️ gap, not a greenlit choice, and none is documented in Foundation yet.
Function
Role
Vendor
Product
SOPs & internal knowledge base
Ops / All staff
Trainual
⚠️ Trainual (keep vs migrate to Notion unresolved)
Every ⚠️ / ⏸ row above, collected so an agent never treats an in-flight bake-off as a locked choice.
⏸ Deferred — a decision consciously postponed:
Compliance / GRC (HIPAA program) — Vanta / Drata, deferred to Q1 2027 (pre-go-live). The underlying HIPAA controls still apply now regardless of tooling.
HIPAA Compliance — the trust zone, vendor BAAs, and PHI-free surfaces
Incubation Workflow (GitHub Spark)
Rapid prototyping with GitHub Spark and a zero-friction graduation path into the Foundation production monorepo.
Incubation Workflow (GitHub Spark)
The Incubation Workflow allows for zero-friction prototyping using GitHub Spark. Use Spark to vibe the initial UI and logic of a feature or micro-app. Once the idea has legs, use the Foundation agentic pipeline to port the code into the production monorepo.
The Service-First Rule
To ensure graduation is a near 1:1 operation, every Spark project must follow the Service Abstraction Pattern. This prevents lock-in to Spark's managed runtime (window.spark).
Spark Context File
When starting a new Spark project, upload or paste the following rules into the Spark editor as context. A ready-to-use template is available for download:
SPARK-RULES.md lives in public/templates/SPARK-RULES.md. To bootstrap a new Spark session, cat public/templates/SPARK-RULES.md and paste the output into the Spark editor.
Spark Context: Foundation Portability Rules
Directory Structure: All side effects must live in a src/services/ directory.
Data Abstraction: Create a DataService.ts. Never call window.spark.kv directly in a component. Wrap it in methods like getData() and setData() (use domain-specific names in practice, e.g., getUserProfile(), saveUserProfile()).
AI Abstraction: Create an AIService.ts. Wrap window.spark.llm in a typed function.
Auth Abstraction: Create an AuthService.ts. Use a placeholder for user identity that can be swapped for Better Auth.
Styling: Use Tailwind CSS exclusively. Avoid custom CSS files to ensure compatibility with the Foundation design system.
The Graduation Process
When you are ready to move a Spark project into the Foundation monorepo, follow these steps:
Step
Action
1. Sync
Ensure the Spark project is synced to a standalone GitHub repository.
2. Target
Identify the target workspace in the Foundation monorepo (e.g., ui/web-app/features/[name]).
3. Port
Use the APPLY-ARCHITECTURE.md prompt with the instruction: "Port this Spark repository to the Foundation architecture. Review APPLY-ARCHITECTURE.md specifically for the Service Mapping rules."
The Incubation Workflow is consistent with the following Foundation principles:
Principle
How it applies
One concern per file
Each service (DataService.ts, AIService.ts, AuthService.ts) addresses exactly one side-effect concern. No service file is responsible for more than one Spark API.
Pre-Commit Check
Ported code must pass the Pre-Commit Check skill before the PR is opened. The skill enforces Biome and Ruff formatting rules that the graduated code must satisfy.
PR Preview Environment
Every graduation PR automatically provisions a PR Preview Environment — an isolated Neon branch plus a dedicated Cloud Run deployment — so the submitter can verify the result before anything touches production.
Monorepo Structure
Monorepo Structure — Turborepo
All Foundation projects are organized as a Turborepo monorepo. This provides unified build orchestration, dependency deduplication, and a single repository for all application code, infrastructure, and configuration.
Package manager: always use pnpm. Never use npm or yarn for JavaScript/TypeScript dependencies. All workspace scripts, CI commands, and documentation assume pnpm. The root package.json declares "packageManager": "pnpm@latest" to enforce this.
Workspaces are declared in pnpm-workspace.yaml (pnpm does not use the workspaces field in package.json):
packages: - "ui/*" - "backend/*" - "schema"
Workspace Conventions
ui/ — Frontend applications and shared UI libraries. Contains deployable apps (web-app/ — Vite + TanStack applications frontend on Cloud Run; marketing/ — Next.js 14 marketing site on Cloud Run; ios/ — Expo React Native) and shared packages (types/, components/, common/). Each has its own package.json, build command, and deployment target.
backend/ — Backend services. Contains the FastAPI HTTP service (api/) and the Restate durable-execution handlers (worker/) for async / agent workflows. Each has its own pyproject.toml, build command, and deployment target.
schema/ — Drizzle Kit package: the single migration history, schema, and RLS policies (TypeScript, its own tsconfig.json). The Python backend reflects this schema — no Alembic.
policies/ — Cerbos authorization policies (YAML) — the app-level policy decision point that pairs with Neon RLS. Not part of the JS or Python workspace graph.
infra/ — Pulumi program (includes restate.ts, which wires the managed Restate Cloud environment — no self-hosted node). Has its own package.json and is not part of the Turborepo workspace graph (deployed separately).
dbt/ — dbt project (BigQuery adapter). Pure SQL + YAML, managed by Python tooling (not part of the JS workspace graph).
TypeScript Configuration
Foundation follows a one tsconfig.json per logical unit rule. A "logical unit" maps to each workspace package (deployable app or shared library package) — not to arbitrary subdirectories inside a package.
Logical Unit
Config File
Covers
Web app
ui/web-app/tsconfig.json
Vite + TanStack app + all source under ui/web-app/
Marketing site
ui/marketing/tsconfig.json
Next.js 14 app + all source under ui/marketing/
Shared package
ui/<package-name>/tsconfig.json
That package's source (e.g., ui/components, ui/types, ui/common)
Mobile / Expo app
ui/ios/tsconfig.json
Expo React Native app + all source under ui/ios/
Schema
schema/tsconfig.json
Drizzle schema + migrations under schema/
Do not create additional tsconfig.json files in nested subdirectories within a workspace package. Each package gets a single tsconfig.json; extra nested configs fragment the type-checking graph, break IDE go-to-definition across package boundaries, and make it harder for tools like Turborepo and Biome to reason about the project.
Typical settings
Each tsconfig should extend a shared base (e.g., @tsconfig/strictest or a root-level tsconfig.base.json):
If present, a root-level tsconfig.base.json at the repo root holds the common strict settings (strict: true, noUncheckedIndexedAccess, exactOptionalPropertyTypes, etc.) so each unit opts in by extending it rather than duplicating options.
Why Turborepo?
Incremental builds. Only rebuilds what changed. Dramatically speeds up CI.
Parallel execution. Tasks across workspaces run concurrently by default.
Dependency graph awareness. Automatically determines build order based on workspace dependencies.
Remote caching. (When enabled) Shares build artifacts across the team and CI.
Simple configuration. Single turbo.json file, no complex plugin system.
Frontend (Web)
Frontend — Applications + Marketing
There are two web surfaces, both deployed to GCP Cloud Run (no Vercel):
ui/web-app — the applications UI. A full-stack Vite + TanStack server (Router + Query, TypeScript) running on Node → GCP Cloud Run — not a static SPA. Its server runtime hosts Better Auth and server-side Drizzle data access, so it needs runtime secrets (DATABASE_URL, BETTER_AUTH_SECRET) — not just build-time config. This is where authenticated, PHI-touching product features live.
ui/marketing — the marketing site. Built with Next.js 14 (App Router), TypeScript. Public content only — no auth, no PHI.
Core Stack
Aspect
Applications (ui/web-app)
Marketing (ui/marketing)
Framework
Vite + TanStack (Router + Query)
Next.js 14 (App Router)
Language
TypeScript (strict)
TypeScript (strict)
Hosting
GCP Cloud Run
GCP Cloud Run
Auth
Better Auth (hosted here)
None
Data access
Drizzle → Neon (direct Postgres, RLS)
None (static / CMS content)
Testing
Vitest + Testing Library
Vitest + Testing Library
Coverage
≥ 80%
≥ 80%
Auth — Better Auth (on the applications-UI server)
Authentication is Better Auth, self-hosted by the applications-UI server (the Vite + TanStack app's server runtime) — not the marketing site.
Better Auth stores its tables in Neon (Postgres adapter) → inside the HIPAA boundary; auth branches with each Neon branch for free.
It issues sessions and a JWKS endpoint. The FastAPI backend and Neon RLS (pg_session_jwt) validate those JWTs so auth.user_id() drives row access.
Supports email/password, OAuth social, sessions, and 2FA / SSO plugins.
The marketing site (Next.js) hosts no auth and touches no PHI. Only the applications-UI server runs Better Auth and connects to Neon.
Serverless notes (Cloud Run scale-to-zero)
Better Auth is a request-handler library, not an always-on service — all its state (users, sessions, JWKS keys) lives in Neon, so it bundles into the ui/web-app container and scales to zero cleanly (no background worker). Configure it for serverless:
Rate-limit storage — the default is in-memory, which is wrong for scale-to-zero / multi-instance (not shared, wiped on every cold start). Point it at Neon (or a secondary store). This is the one that bites people.
Session validation — enable cookieCache so most requests skip a Neon round-trip on session lookup, cutting latency + DB load when bursty cold starts all hit the database.
Connections — use Neon's pooled connection string so a burst of Cloud Run instances doesn't exhaust Postgres connections.
JWKS — have the FastAPI API cache the JWKS (long TTL) so a request during an idle window doesn't cold-start the app server just to fetch keys.
Optional: set min-instances=1 on ui/web-app only if auth-path cold-start latency matters — a cost/latency trade-off, not a requirement.
Data access (applications UI)
Server-side only, via Drizzle ORM over a direct Neon Postgres connection — never the Neon Data API (it sits outside the HIPAA boundary). Drizzle-managed RLS (crudPolicy, authenticatedRole) — defined in the top-level schema/ package alongside the tables and migrations — keeps authorization reviewable through the PR schema-diff gate. See Backend and HIPAA Compliance.
Browser → API calls
The applications UI talks to two backends, and the auth token flows through both:
The ui/web-app server issues the Better Auth session and a signed JWT (Better Auth's JWKS endpoint lives here — see above).
Browser calls to the Python backend/api go through the generated openapi-fetch client (types + client in src/lib/api/, see Backend), which attaches the Better Auth JWT as a bearer token.
backend/apivalidates that JWT against the JWKS (BETTER_AUTH_JWKS_URL) and binds it to the Neon connection, so Neon RLS (pg_session_jwt + the authenticated role) keys row access on auth.user_id().
So the same Better Auth identity that gates the UI server also drives DB-level row isolation on the API — no second login, one identity end-to-end. (Mobile does the same with its session token — see Mobile.)
Environment Variables
Variable
Surface
Public?
Description
DATABASE_URL
app server
No
Neon connection (Better Auth + Drizzle)
BETTER_AUTH_SECRET
app server
No
Better Auth signing secret
VITE_API_URL
app
Yes
FastAPI API base URL (inlined at build)
NEXT_PUBLIC_API_URL
marketing
Yes
Base URL for the marketing site
VITE_* / NEXT_PUBLIC_* values are inlined into client bundles at build time — they must be safe to expose. Server-only secrets (DATABASE_URL, BETTER_AUTH_SECRET) come from GCP Secret Manager and are never sent to the browser.
openapi-typescript + openapi-fetch — typed API client generated from the FastAPI OpenAPI schema into src/lib/api/ (via pnpm gen:api; see Backend)
posthog-js — product analytics + session replay on the client (masking on — see below)
Marketing (ui/marketing) — runtime
next
Dev / Test (both)
vitest + @testing-library/react
Product analytics — PostHog (posthog-js)
ui/web-app initialises posthog-js on the client for product analytics, feature flags, experiments, session replay, and client error tracking. Server-side flag evaluation / event capture uses posthog-node (single long-lived client, local evaluation) as on the marketing site. PostHog's full role across the stack (analytics + flags + experiments + replay + client error tracking + LLM observability & evals) is described in Observability and Content Management.
Because replay runs on clinical screens, masking is not optional — this closes a real HIPAA gap:
Turn on session-replay masking: maskAllInputs: true and mask clinical text (maskTextSelector covering PHI-bearing elements).
Identify by opaque user IDs only — never a name, email, or MRN.
Keep no PHI in event names, property keys, or flag keys. See HIPAA Compliance.
// lib/analytics.tsimport posthog from 'posthog-js'posthog.init(import.meta.env.VITE_POSTHOG_KEY, { api_host: import.meta.env.VITE_POSTHOG_HOST, session_recording: { maskAllInputs: true, // never record raw input values maskTextSelector: '[data-phi]', // mask clinical text nodes },})
Cloud Run Deployment
Both surfaces are containerised and deployed to GCP Cloud Run, matching the backend worker. Each has its own Dockerfile; the marketing Next.js build uses output: 'standalone'.
Using Cloud Run for all web + backend keeps everything under one provider, one set of IAM policies, and one GCP BAA.
Mobile
Mobile — ui/ios (Expo)
The mobile application uses the Expo managed workflow with React Native and TypeScript.
Core Stack
Aspect
Choice
Framework
Expo SDK (latest stable)
Navigation
expo-router
Language
TypeScript (strict mode)
Auth
Better Auth (Expo client → the applications-UI server)
The mobile app authenticates against the same Better Auth instance the web applications use — hosted by the applications-UI server (see Frontend), backed by Neon. Use the Better Auth Expo client plugin; a login screen is scaffolded at app/(auth)/login.tsx via expo-router. The app never talks to Neon directly — it calls the FastAPI API with the Better Auth session token.
No PHI is cached on-device without review. Tokens live in secure storage (expo-secure-store).
Environment Configuration
Environment variables are provided via app.config.ts reading EXPO_PUBLIC_* vars:
The mobile app uses posthog-react-native for product analytics, feature flags, experiments, and session replay, feeding the same PostHog project as the web app. PostHog's full role across the stack is described in Observability.
The same masking discipline applies — replay can capture clinical screens, so:
Enable session-replay masking: maskAllTextInputs: true and maskAllImages: true, plus wrap PHI-bearing views in <PostHogMaskView> (or the ph-no-capture marker).
Identify by opaque user IDs only — never a name, email, or MRN. Keep no PHI in event names, property keys, or flag keys. See HIPAA Compliance.
// app/_layout.tsx — wrap the router in the PostHog providerimport { PostHogProvider } from 'posthog-react-native'export default function RootLayout() { return ( <PostHogProvider apiKey={process.env.EXPO_PUBLIC_POSTHOG_KEY!} options={{ host: process.env.EXPO_PUBLIC_POSTHOG_HOST }} autocapture={{ captureScreens: true, // Session replay masks PHI by default on-device. sessionReplay: { maskAllTextInputs: true, maskAllImages: true }, }} > {/* expo-router <Slot /> / <Stack /> */} </PostHogProvider> )}
Testing
Tests run on Vitest with @testing-library/react-native, enforcing ≥ 80% coverage via Vitest's v8 provider.
Because React Native ships no DOM, Vitest needs an RN-aware setup rather than the old jest-expo preset: alias react-native to react-native-web (or an RN-compatible resolver) so components resolve under Node, register @testing-library/react-native in a setup file, and run in a jsdom environment. Example vitest.config.ts:
// vitest.config.tsimport { defineConfig } from 'vitest/config'export default defineConfig({ // Resolve the RN entrypoint under Node — RN's native modules can't load in Vitest. resolve: { alias: { 'react-native': 'react-native-web', }, }, test: { // jsdom gives react-native-web a DOM to render into. environment: 'jsdom', // Registers @testing-library/react-native matchers + cleanup and RN globals. setupFiles: ['./vitest.setup.ts'], globals: true, coverage: { provider: 'v8', thresholds: { branches: 80, functions: 80, lines: 80, statements: 80, }, }, },})
The setup file registers the RN matchers and auto-cleanup so every test file gets them without repeating boilerplate:
// vitest.setup.tsimport '@testing-library/react-native/extend-expect' // toBeVisible(), toHaveTextContent(), …import { cleanup } from '@testing-library/react-native'import { afterEach, vi } from 'vitest'// Unmount the rendered tree after each test to avoid cross-test state leaks.afterEach(() => cleanup())// Native-only modules aren't available under Node — stub the ones tests touch.vi.mock('expo-secure-store', () => ({ getItemAsync: vi.fn(), setItemAsync: vi.fn(), deleteItemAsync: vi.fn(),}))
Backend
Backend — API (backend/api) & Durable Worker (backend/worker)
The backend is two Python services on GCP Cloud Run, both backed by Neon (Postgres):
backend/api — a FastAPI HTTP service (request/response; the typed OpenAPI contract).
There is no Supabase client and no Neon Data API on the Python side. The API talks to Neon over a direct Postgres connection. The Neon Data API (PostgREST) sits outside the HIPAA boundary and must never carry PHI.
Dev / Test
Package
Purpose
ruff
Linting + formatting
pytest
Test runner
pytest-cov
Coverage reporting
httpx
Async HTTP client for testing FastAPI
Configuration — config.py
All configuration is managed via pydantic-settingsBaseSettings:
Never log secret values. All settings are read from environment variables and must not appear in log output. Never log DATABASE_URL.
Data access — Neon (Postgres)
The API talks to Neon directly over Postgres — no Supabase client, no Data API:
SQLAlchemy or raw asyncpg over a direct connection.
Row-Level Security is enforced in Neon. Per request, backend/api validates the caller's Better Auth JWT against the JWKS (BETTER_AUTH_JWKS_URL) and then sets that JWT on the Neon connection/transaction — Neon's pg_session_jwt extension plus the authenticated role expose the claims to SQL, so RLS policies keyed on auth.user_id() drive row access. See Libraries and HIPAA Compliance.
RLS policies are the single source of truth for authorization; migrations + policy diffs go through the PR schema-diff gate (see PR Environments).
Schema + migrations (including RLS) are owned by Drizzle Kit — one migration history. The Python side reflects the schema (SQLAlchemy automap / generated models); it does not run Alembic.
Authorization — Cerbos + RLS
Authorization is two-layered, and the two layers stay coherent by design:
Cerbos is the application-level policy-decision-point (PDP). It decides relationship and role/attribute policy — "may this user perform this action on this resource?" — from policy-as-code (YAML resource policies + CEL conditions). backend/api calls the PDP per request (during HTTP request authorization) via the Python cerbos SDK. The policies live in a version-controlled, top-level policies/ directory (Cerbos YAML) loaded by the self-hosted Cerbos PDP.
Neon Row-Level Security (RLS) remains the enforcement floor. Even if an app-layer check is missed, RLS blocks the row at the database. RLS is the last line and is non-negotiable for PHI tables.
The two layers are layered, not the same rule — defense in depth. Neon RLS is the coarse enforcement floor: tenant/row isolation, deny-by-default, always enforced by the database no matter what the app does. Cerbos adds a fine-grained relationship/action policy on top, at the app layer. Instead of asking "can this user see resource X?" one row at a time, backend/api asks Cerbos's Query Plan API for a query plan and receives a set of conditions that compile to a SQL WHERE filter. That filter is applied in addition to RLS on the same query — so a request must satisfy both the DB floor and the app-layer policy. The two are not expected to be identical rules and can legitimately differ in granularity (RLS the tenant/row floor, Cerbos the action/relationship detail); layering them is the point.
Cerbos is self-hosted, stateless, and PHI-free. It runs as a stateless container in GCP (Cloud Run or a sidecar next to the API) and holds no data at rest. Its inputs and decisions are opaque IDs, roles, and attributes only — never PHI — so no vendor BAA is required and it stays fully in-boundary. See HIPAA Compliance.
Escalation path: Cerbos covers role/attribute and direct-relationship policy well. If the permission model grows into a deep or transitive graph (e.g. nested group hierarchies, "can access because a parent-of-a-parent shared it"), escalate to a relationship-based engine — OpenFGA or SpiceDB (Zanzibar-style). Do not reach for that complexity until the graph actually demands it; Cerbos + RLS is the default.
API contract — OpenAPI → typed client
FastAPI generates an OpenAPI schema from the routes + Pydantic models automatically. That schema is the contract between the API and the TypeScript app — there is no hand-written, drift-prone client.
The API serves its schema at /openapi.json (FastAPI built-in).
ui/web-app generates a typed client into ui/web-app/src/lib/api/ with openapi-typescript (types) + openapi-fetch (a tiny typed fetch wrapper), via a pnpm gen:api script. Types flow end-to-end: change a Pydantic model and every affected call site in the app becomes a TypeScript error.
The generated client is committed and regenerated in CI; a drift-check job in ci.yml re-runs pnpm gen:api and fails the build on any diff between openapi.json and the committed types, so the contract can never silently rot.
This is the "schema-first, typed contract" the AI-native workflow relies on: an agent that edits a FastAPI route regenerates the client and immediately sees every frontend call that no longer type-checks — the contract is enforced at build time, not discovered at runtime.
Durable / async jobs — backend/worker (Restate)
Long-running and event-driven work runs in backend/worker on the Restate durable-execution layer (retriable, resumable steps) — not inline in the API's request handlers. The worker's durable handlers (in backend/worker/src/jobs/) are registered as a deployment with Restate Cloud via the Restate ASGI app in main.py; Restate Cloud then invokes, journals, and re-drives them on retry, calling the worker's signed public endpoint. Example: sync_wellsky_changes (backend/worker/src/jobs/sync.py):
Trigger:wellsky/sync.requested — emitted by backend/api (e.g. from a WellSky webhook route) and delivered to the handler by Restate Cloud.
Steps: Fetch changes from WellSky → upsert into Neon → sync analytics rows to BigQuery
PHI discipline: event payloads carry opaque, short-lived IDs only — never stable DB primary keys — and the function fetches PHI from Neon inside the boundary. Short-lived IDs are the primary entity identifier so the metadata Restate does hold can't become a durable re-identification vector. This keeps PHI out of the orchestration layer regardless of vendor.
Durable-execution engine: Restate Cloud (managed). The engine must push work over HTTP and suspend to zero on Cloud Run (pull-based polling-worker models are disqualified). We chose Restate over Inngest because the product's core is agentic, long-running, human-in-the-loop workflows (chat → design a care schedule → implement → pause for a human kickoff call → activate). Restate's virtual objects + durable promises make "await a human" and suspend-to-zero first-class — exactly this shape — where Inngest's one-shot waitForEvent was the weaker fit. It runs on Restate Cloud (managed, serverless, us region): Restate Cloud drives execution and calls the worker's signed public Cloud Run endpoint, and the ASGI/HTTP handlers scale to zero between steps — so there is no always-on node to operate. The Python SDK is restate-sdk (1.0); it is ASGI (Hypercorn), so there is a little glue vs FastAPI-native.
PHI: the initial posture is that no PHI is shared with Restate Cloud at all — there is no Restate BAA. Restate journals more than a plain function runner — ctx.run results, call args/results, awakeable payloads, and virtual-object state (ctx.set) — so any PHI that must be journaled is encrypted at the serde boundary inside the handler via a GCP-KMS-backed encrypting Serde (use a typed PHI[T] wrapper whose serde always encrypts), and Restate Cloud's journal and state hold ciphertext only — Restate Cloud never sees plaintext PHI. Because the serde does not encrypt keys, the primary entity identifiers are opaque, short-lived IDs (not stable DB primary keys), so object keys, state-key names, error messages/exceptions, and trace IDs stay PHI-free and non-correlatable. Restate Cloud therefore acts as an encrypted conduit and needs no vendor BAA; if the product later chooses to share PHI with Restate, that decision requires a signed BAA first. The rationale lives in the Compute Stack Plan (Notion).
HIPAA: PHI (patient names, MRNs, DOBs) must never appear in log output on Cloud Run / Cloud Logging, nor in durable-job payloads. See the HIPAA Compliance page.
Dockerfile
Multi-stage build optimized for Cloud Run:
Stage
Purpose
builder
Install Python dependencies via uv
runtime
Copy only installed packages + source code, run as non-root user
The runtime stage exposes port 8080 (Cloud Run default).
Decision: the entire AI tier runs on Google Gemini via Vertex AI. One Google Cloud BAA covers reasoning, embeddings, and voice — in the same cloud as the app, with PHI staying in-boundary. We break a subcomponent out to another provider only when a concrete, evidenced need forces it (see Escalation). The full provider bake-off lives in the Compute Stack Plan (Notion).
The AI tier is inside the trust zone — unlike the durable-execution layer, it does receive PHI (that's the point of clinical reasoning), under the GCP BAA. It is not on the PHI-free service list. PHI must still be scrubbed from logs and spans.
Subcomponents
Each is separable — the boundary and BAA are shared, so swapping one (e.g. the reasoning model) doesn't disturb the others.
Subcomponent
Service
Model / API
Notes
Reasoning & agent orchestration
Vertex AI
Gemini 3.1 Pro
The agent brain — tool-calling + clinical-context reasoning. Runs as a durable workflow (see Backend).
In-boundary embeddings; vector search lives in Neon under RLS (see Data Pipeline).
Voice / realtime
Vertex AI
Gemini Live API + Chirp (STT/TTS)
Low-latency speech for the voice agent. Phase-2 — see the Voice plan (Notion).
Client SDK: the Google Gen AI SDK (google-genai), configured for Vertex. All three subcomponents — reasoning, embeddings, and voice — go through the single google-genai Python SDK, initialised in Vertex mode (genai.Client(vertexai=True, project=..., location=...)), never the ai.google.dev Gemini Developer API (not BAA-covered). It's a runtime dependency of backend/worker (the agent runtime) and any embedding jobs; see Libraries.
Why one provider
One boundary, one BAA. Reasoning + embeddings + voice all sit inside the Google Cloud BAA, in the same cloud as the Cloud Run app — no third-party inference hop, no second contract.
Gemini uniquely closes two gaps that bite single-model shops: in-GCP HIPAA-covered embeddings (gemini-embedding-001) and a realtime voice path with named-covered STT/TTS (Chirp). Claude, for example, offers neither and would force a second provider for both.
Fewer moving parts beats marginal per-task model quality at this stage.
HIPAA posture (required)
BAA: Vertex AI is covered under the Google Cloud BAA (on the covered-products list as "Generative AI on Gemini Enterprise Agent Platform"). Never use the ai.google.dev Gemini Developer API or AI Studio — not BAA-covered.
Region: pin to a US region, not global (global lacks CMEK / Access Transparency).
Retention: request abuse-logging opt-out / Zero Data Retention (default abuse logging retains prompts up to 90 days).
Isolation: VPC-SC + CMEK; a separate GCP project for HIPAA workloads.
Schema hygiene: never put PHI in JSON-schema field names / enums / regex (schemas are cached without PHI protections).
Escalation — break out only when forced
Default everything to Gemini/Vertex. Reach for another provider only when a specific, evidenced need demands it — and even then, prefer to stay inside a BAA boundary. Both of these are subcomponent-level swaps, not a tier rewrite:
Reasoning quality — if an eval shows Gemini losing materially on our concierge / clinical tasks, escalate that subcomponent to Claude on Vertex AI (still the same GCP BAA, same boundary).
Retrieval latency — if RAG traffic needs to be co-located with Neon, run embeddings on Bedrock (us-west-2) (the AWS BAA, same region as the DB).
The bake-off and trade-offs behind this are documented in the Compute Stack Plan (Notion).
Agentic Architecture
Agentic Architecture — Multi-Agent Care Orchestration
The system behind agent-driven onboarding, scheduling, and care ops — the product's core differentiator. It is a multi-agent, orchestrated design: a durable Restate orchestrator (backend/worker) sequences specialist Gemini agents, grounded on an append-only care-context event log in Neon + pgvector, with human-in-the-loop gates and everything held inside the Vertex BAA boundary (see AI Tier and HIPAA Compliance).
Locked: Gemini (Vertex) brain · custom agent loop on Restate (durable runtime, backend/worker) · Neon + pgvector memory/moat · multi-agent, orchestrated · PHI in Neon under RLS, agent I/O never leaves the boundary. This is a buildable architecture spec, not a pitch — where a choice is still open it is fenced as WIP and enumerated in Open questions.
1. Topology — multi-agent, orchestrated
Orchestrator — a durable Restate workflow in backend/worker that owns the end-to-end journey (prospect → onboarded client), routes to specialists, holds durable state, and enforces the human-in-the-loop gates.
Specialist agents — each a Gemini loop with a scoped toolset:
Agent
Job
Front door
Intake
Converses with prospect/family, captures care needs, qualifies
Front door of the HomeCareHQ funnel
Care-planner
Turns needs into a proposed care plan (services, hours, caregiver attributes)
—
Scheduler
Matches caregivers (availability, geography, skills), designs + commits the schedule
—
Later
Billing, change/re-schedule, QA/eval agents
—
Each specialist is a sub-workflow / durable step the orchestrator invokes; the orchestrator sequences them and owns the kickoff gate. Conversational surfaces (the ui/web-app and, later, voice) drive the intake agent through the orchestrator, never the model directly.
2. The agent loop
Each agent is Gemini via Vertex (SDK google-genai, vertexai mode) running a bounded reason → tool-call → observe loop. Model tier and BAA/region posture are governed by the AI Tier decision (US region, abuse-logging opt-out, no ai.google.dev).
Tools are typed functions (JSON-schema, derived from the OpenAPI / Pydantic contract in backend/api) that:
read/write Neon under RLS — the agent acts as the session identity, so it sees only entitled rows;
call WellSky (referrals, EVV, caregivers);
retrieve from the care-context log + pgvector;
take actions — propose plan, commit schedule, send comms via Telnyx / AWS SES.
Determinism boundary: each LLM call and each tool call is wrapped as a Restate durable step — journaled and retriable. On crash the workflow resumes exactly where it left off, and already-committed side-effects are not re-run on replay.
Schema hygiene (HIPAA): never put PHI in JSON-schema field names, enums, or regex — tool schemas are cached without PHI protections. See AI Tier → HIPAA posture.
3. Memory & the data moat
Short-term (conversation + working state) lives in Restate workflow state — durable, survives restarts, no separate session store.
Long-term = the moat: an append-only care-context event log in Neon — every interaction, decision, plan, schedule change, and outcome captured as structured events. This is the proprietary dataset.
Retrieval:pgvector over embeddings (gemini-embedding-001) of the log + notes, RLS-scoped so retrieval only surfaces entitled context. Agents ground via RAG, not prompt-stuffing.
Embeddings are computed in-boundary — clinical text is PHI, so it is embedded inside the Vertex BAA boundary; never an out-of-boundary embedder.
A BigQuery mirror (via dbt) serves analytics / eval — de-identified where it leaves the boundary (see Data Pipeline).
The event-log schema is defined in schema/ (Drizzle) alongside the rest of the Neon model.
4. Durability + human-in-the-loop (why Restate)
The signature flow — chat → design schedule → implement → human kickoff call → activate — is long-running, suspendable, and human-gated.
Restate durable promises make "await a human" a first-class primitive: the workflow suspends to zero (scale-to-zero on Cloud Run) while waiting for a human action, then resumes deterministically — no polling, no lost state.
Approval gates: schedule commit + go-live block on a durable promise resolved by a care-coordinator UI action in the ui/web-app.
Idempotency: each step is keyed; WellSky / SES / Telnyx calls are idempotent + journaled, so retries never double-book or double-send.
5. Boundary, safety & governance (HIPAA)
In-boundary: Gemini via Vertex (BAA); PHI in Neon (RLS); retrieval in-zone. No agent I/O to Notion or any no-BAA surface (see HIPAA Compliance).
PHI-free telemetry: agent traces/logs carry opaque IDs only; full transcripts (if kept) live in Neon and are redacted in Cloud Logging. This keeps the observability plane on the PHI-free service list.
Tool-level authz = RLS + Cerbos — the agent can't touch what the acting identity can't. Cerbos supplies policy-as-code decisions (Query Plan → SQL filters) over the Neon RLS floor; each agent gets a least-privilege toolset.
Guardrails / eval: in/out PHI-leak filters on anything crossing a boundary; an eval harness (golden transcripts) gates prompt/tool changes; human-in-the-loop for high-stakes actions.
Auditability: the event log + the Restate journal together form a complete, replayable audit trail of every agent decision and action.
6. Open questions
See also:
Backend — backend/api, the backend/worker Restate runtime, Cerbos, Neon RLS, and schema/
AI Tier — Gemini on Vertex, embeddings, and the HIPAA posture for the model layer
HIPAA Compliance — the trust zone, vendor BAAs, and PHI-free telemetry
Infrastructure
Infrastructure as Code — infra/ (Pulumi, TypeScript)
Infrastructure is managed using Pulumi with TypeScript, spanning Google Cloud Platform (compute, registry, secrets), Neon (Postgres), and Cloudflare (DNS + CDN). State lives on Pulumi Cloud — no self-managed state bucket.
Every third-party in the stack now has a first-class Pulumi provider, so there are no "manual setup notes" resources. Neon is provisioned by the Neon Pulumi provider, GCP by the Google provider, and DNS by the Cloudflare provider.
Pulumi state and secrets encryption are hosted on Pulumi Cloud (app.pulumi.com). This gives the team drift detection, per-stack history, and a shared encryption key without operating a state bucket.
Stack
Purpose
prod
Production GCP + Neon + Cloudflare
dev
Shared non-prod resources
Per-PR preview resources (Cloud Run web-app-pr-<n> / api-pr-<n> and the Neon pr-<n> branch) are not managed by Pulumi — they are created and destroyed directly by GitHub Actions. See PR Environments.
GCP Resources — gcp.ts
Cloud Run Services
Four services, all containers from Artifact Registry:
Durable-execution / async job handlers → Neon; invoked by Restate Cloud over signed HTTPS, suspend-to-zero between steps
Setting
Value
Rationale
maxScale
10
Cost control; adjust based on load
timeoutSeconds
3600
worker only — 60-minute ceiling for long WellSky sync steps. web-app / api / marketing keep normal request timeouts (default 300s).
Image source
Artifact Registry
Same-project, no cross-project IAM needed
Cloud Run IAM
Public invocation is allowed for the marketing site, the /health liveness probe, and the worker's Restate ingress path only. The worker endpoint is public at the Cloud Run IAM layer because Restate Cloud is an off-GCP caller — it is authenticated in the handler by verifying Restate Cloud's request signature, not by Cloud Run IAM.
Production: Lock down Cloud Run IAM with proper authentication (--no-allow-unauthenticated, access via IAM / IAP). The open /health endpoint is illustrative only.
Restate Cloud wiring — restate.ts
The Restate durable-execution runtime is Restate Cloud — a fully managed, serverless Restate environment (region us), not a self-hosted server. There is no GCE VM, no persistent disk, and no Rust node to operate: Restate Cloud holds the durable state and drives execution, invoking the worker's HTTP handlers and suspending them to zero between steps. restate.ts therefore provisions no compute — it only wires the connection: the Restate Cloud environment endpoint/region and the request-signing public key, materialized into Secret Manager for the worker.
Runtime: managed by Restate Cloud off-GCP. Because it is serverless there is no always-on node in our VPC to size, patch, or pay for at idle.
Invocation: Restate Cloud calls the worker's public HTTPS endpoint and each request is signed with the environment key; the worker verifies the signature in-handler. Handlers suspend between steps, so worker still scales to zero when idle.
Config: the worker service reads RESTATE_* (Restate Cloud environment endpoint + identity/signing keys) from Secret Manager. See Environment Variables and Backend.
No PHI is shared with Restate Cloud (initial posture). Any PHI that must be journaled is encrypted at the serde boundary inside the handler (GCP-KMS-backed encrypting Serde), so Restate Cloud's journal and virtual-object state hold ciphertext only, and the primary entity identifiers are opaque, short-lived IDs — so keys, trace IDs, and call structure stay PHI-free and non-correlatable. Restate Cloud is therefore treated as an encrypted conduit and needs no vendor BAA. See HIPAA Compliance and Backend.
Artifact Registry
Docker image repository for the web-app, marketing, api, and worker services.
Images are tagged with commit SHA for traceability.
Secret Manager
Runtime store for application secrets, materialized from Pulumi ESC and mounted into Cloud Run at deploy time. See Secret Management.
Workload Identity Federation
GitHub Actions authenticates to GCP via Workload Identity Federation (GitHub OIDC) — no long-lived service-account JSON keys are stored as secrets.
BigQuery Dataset
Name:raw_data
Location: US
Landing zone for the incremental Neon → BigQuery extract-load (see Data Pipeline).
Object Storage — GCS (user uploads / documents)
User-uploaded files and generated documents live in Google Cloud Storage, inside the trust zone:
CMEK — buckets are encrypted with customer-managed encryption keys (Cloud KMS), not just Google-default keys.
Signed URLs — clients never get direct bucket ACLs; the API mints time-limited signed URLs for upload/download so access is scoped and auditable.
No public buckets. Uniform bucket-level access; PHI-bearing objects stay private and are served only via signed URLs.
PHI-bearing uploads live in GCS inside the trust zone; the full PHI-at-rest list (Neon · GCS · Vertex AI · WellSky · the governed M365 plane) is on the HIPAA Compliance page.
Neon — neon.ts
Neon is managed by Pulumi via the Neon Pulumi provider — the project, its HIPAA posture, branches, and roles are all declarative resources:
Project on the Scale plan, region AWS us-west-2 (same metro as Cloud Run us-west1 to minimize cross-cloud egress).
hipaa: true set on the project (irreversible; sets audit_log_level=hipaa). The BAA is self-serve on Scale — no separate add-on.
Extensions:pgvector (embeddings) and pg_session_jwt (Better Auth JWT → RLS auth.user_id()).
Roles & authorization: least-privilege application role(s). Neon RLS is the enforcement floor — the last-line database guard that holds even if app code is wrong — while Cerbos (a self-hosted, stateless PDP running in-boundary) is the app-level policy decision point for richer, contextual authorization. RLS schema + migrations live in the top-level schema/ dir (owned by Drizzle Kit); Cerbos policies live in the top-level policies/ dir. RLS is the floor, not the authorization source of truth.
Branches: the primary branch plus the copy-on-write branch-per-PR model — ephemeral pr-<n> branches are created by CI, not Pulumi.
The Neon Data API (PostgREST) is not enabled for PHI paths — it sits outside the HIPAA boundary. Application and API traffic use direct Postgres connections. See HIPAA Compliance.
Edge & WAF — split by PHI exposure
The edge is deliberately split so that PHI never transits a vendor we don't need a BAA with:
Surface
Fronted by
Why
Marketing site + DNS (no PHI)
Cloudflare (proxied CDN + DDoS + DNS)
Static, public, PHI-free — Cloudflare's CDN/WAF is a good fit and needs no BAA because no PHI flows through it.
Web app + API (carry PHI)
GCP Cloud Load Balancing + Cloud Armor (WAF)
Keeps the PHI path entirely inside the Google BAA boundary. PHI never transits Cloudflare, so no Cloudflare Enterprise / Cloudflare BAA is required.
Do not proxy the PHI-carrying web-app/API through Cloudflare. Terminate and protect those origins with Cloud Load Balancing + Cloud Armor so all PHI stays within GCP. Cloudflare handles only the marketing surface and DNS.
Cloudflare — cloudflare.ts
DNS and CDN are managed with the Cloudflare Pulumi provider:
DNS records for the apex + subdomains. Marketing points at its Cloud Run service via the Cloudflare proxy; the web-app / API hostnames are DNS-only (grey-cloud), resolving to the GCP load balancer so their traffic bypasses Cloudflare entirely.
Proxied (orange-cloud) endpoints for CDN + DDoS protection on the PHI-free marketing surface only.
Tailnet Access — Tailscale (non-prod dev surfaces)
Every non-production surface — per-PR previews, the shared dev / staging env, and the Coder remote-dev workspaces — is reachable only from the company tailnet (Tailscale). Production is unaffected. One tailnet is the single "dev network," so there is no second access mechanism to operate.
Shape — one internal LB, wildcard DNS, tailnet-only:
Preview / staging Cloud Run services deploy with ingress = internal-and-cloud-load-balancing — no public *.run.app URL. They are reachable only through the internal load balancer.
One regional internal Application Load Balancer (a private VIP in the VPC) does host-based routing. A wildcard Cloud DNS private zone*.pr.internal resolves to the LB VIP, so there is no per-PR DNS record — CI adds a serverless NEG + one URL-map host rule per PR (web-app-pr-<n>.pr.internal, api-pr-<n>.pr.internal) and removes them on teardown. One LB, N backends.
A Tailscale subnet router — one always-on node (small GCE VM; infra/tailscale.ts) — advertises the route to the LB VIP into the tailnet and pushes split-DNS for *.pr.internal. A Tailscale ACL scopes reachability to the dev group.
Net effect: a dev on the tailnet opens https://web-app-pr-42.pr.internal; it resolves to the internal LB, host-routes to the right service — and nothing off the tailnet can reach it (the LB has no public IP; the only ingress path is the subnet router). This is network-level isolation, stronger than a public endpoint gated by IAP.
The same tailnet reaches Coder. The Coder-in-private-subnet workspaces are reached over this same tailnet — one VPN for previews, staging, and remote dev.
Tailscale carries no PHI and needs no BAA. Previews / staging run on the non-PHI Staging Neon project, so no PHI ever transits the tailnet. The one path where PHI could ride the tunnel is Coder → Neon Production; Tailscale is WireGuard, end-to-end encrypted (its DERP relays pass ciphertext only and never see plaintext), so it is treated as a HIPAA conduit (like an ISP) — no Tailscale BAA required. Never terminate PHI plaintext on a Tailscale relay/coordination hop.
Production stays public. The prod app / API remain on GCP Cloud LB + Cloud Armor, and marketing on Cloudflare — only non-prod is tailnet-gated.
Caching — Redis deferred
There is no Redis in the initial stack. The two things a cache would carry are handled in-database for now:
Better Auth rate-limit storage and light caching live in Neon (Postgres). Better Auth supports a database-backed rate limiter, which is sufficient at launch volumes.
Add Memorystore (Redis) only when real pressure appears — hot-path read latency, rate-limit write contention, or session/cache volume that Postgres shouldn't absorb. It slots in as a managed GCP resource (in-boundary) when the need is demonstrated, not preemptively.
Deferring Redis keeps one fewer moving part in-boundary. Revisit when metrics (Neon CPU on rate-limit tables, p95 read latency) justify a dedicated cache.
The data pipeline syncs Neon (OLTP / application database — the source of truth) into BigQuery (OLAP / analytics warehouse). Neon is where the app reads and writes; BigQuery is where analytics, reporting, and data-moat modeling run. Data flows app → warehouse (the reverse of the older Supabase-era pipeline).
Architecture
App (Cloud Run) ──writes──▶ Neon (Postgres · OLTP · source of truth)
│
extract-load (incremental, nightly)
▼
BigQuery (raw_*)
│
dbt (transform, in BigQuery)
▼
BigQuery (marts) ──▶ BI / analytics / ML
The app reads/writes Neon for low-latency OLTP.
A scheduled extract-load job copies changed rows from Neon into BigQuery raw_* tables — incrementally, WHERE updated_at > <watermark>.
dbt runs against a single warehouse (BigQuery) — it does not read Postgres and write BigQuery in one hop. The Neon → BigQuery move is the extract-load step; dbt then models inside BigQuery. This replaces the old Supabase Wrappers FDW, which does not exist on Neon.
Reads changed rows from Neon and loads them into BigQuery raw tables, watermarked to minimize egress:
# conceptual — pulled from Neon each run, then loaded into BigQuery raw_*SELECT * FROM patients WHERE updated_at > :last_watermark;
Runs as a scheduled Cloud Run Job (neon-to-bq) — a standalone job, not part of the backend/worker service. Cloud Scheduler (or the nightly dbt.yml workflow) triggers it.
Loads into BigQuery raw_* via the google-cloud-bigquery client.
Persists the watermark (max updated_at) so each run transfers only new/changed rows.
Cross-Cloud Egress
Neon runs in AWS us-west-2; BigQuery in GCP. The WHERE updated_at > watermark keeps the Neon → BigQuery transfer incremental — only new/changed records cross the boundary. Full-table cross-cloud scans are prohibited (egress cost + compliance risk).
PHI in the warehouse: either de-identify PHI as it lands in BigQuery, or keep the BigQuery dataset inside the BAA with restricted service-account access. See HIPAA Compliance.
Key Model — active_patients.sql (in BigQuery)
Incremental materialization minimizes reprocessing on each run:
{{ config(materialized='incremental', unique_key='patient_id') }}SELECT patient_id, first_name, last_name, date_of_birth, updated_atFROM {{ ref('stg_patients') }}{% if is_incremental() %}WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }}){% endif %}
Scheduling — GitHub Actions
The pipeline runs nightly via .github/workflows/dbt.yml:
Setting
Value
Schedule
0 3 * * * (3:00 AM UTC daily)
Manual trigger
workflow_dispatch enabled
Steps
trigger the neon-to-bq Cloud Run Job (Neon → BigQuery raw_*) → dbt run --target prod → dbt test (all BigQuery)
Auth
GCP via Workload Identity Federation (dbt → BigQuery, keyless); the extract-load job reads the Neon connection string from GCP Secret Manager
Backups & Recovery
Backups & Disaster Recovery
Every data store has a defined backup mechanism, retention window, and restore path. This is a HIPAA contingency-plan requirement (§164.308(a)(7)) — designed in, not bolted on.
What backs up what
Store
Mechanism
Retention
Restore
Neon (OLTP · source of truth)
Point-in-time restore (PITR) — continuous WAL, restore to any timestamp; plus named branch snapshots
History window set on the Scale plan (confirm current window; typically 7–30 days)
Create a branch at a timestamp, or restore the primary (see runbook)
BigQuery (OLAP)
Time travel + table snapshots + scheduled exports to GCS
7-day time travel; snapshot / export retention per lifecycle
FOR SYSTEM_TIME AS OF, snapshot restore, or reload from GCS
GCS (objects / uploads)
Object versioning + lifecycle rules
Non-current versions kept per lifecycle policy
Restore a prior object version
Config / IaC
Pulumi state on Pulumi Cloud (versioned) + Git history
Full history
pulumi up from a prior state / commit
Neon is the source of truth. BigQuery and GCS are recoverable, but the authoritative recovery target is always Neon — rebuilding BigQuery is a re-run of the extract-load + dbt (see Data Pipeline), not a restore.
RTO / RPO targets
Store
RPO (max data loss)
RTO (max downtime)
Neon (OLTP)
≈ 0 — continuous WAL / PITR
minutes — branch-at-timestamp
BigQuery (OLAP)
≤ 24 h — nightly rebuild from Neon
hours — rebuild via pipeline
GCS (objects)
≈ 0 — versioned
minutes — restore version
These are targets to design and test against, not guarantees. Confirm the Neon retention window on the current plan, and treat the RPO/RTO here as the SLOs the backup config must satisfy.
Restore runbook — Neon
Identify the target time — the last-known-good timestamp, before the incident.
Branch, don't overwrite. Create a Neon branch at that timestamp (neondatabase/create-branch-action / Neon API) and validate the data there first — never restore in place blind.
Cut over — repoint the app's DATABASE_URL (via GCP Secret Manager) to the restored branch, or promote it to primary.
Rebuild downstream — trigger the extract-load + dbt to rebuild BigQuery from the restored Neon state.
Record the event (what, when, blast radius, root cause) for the compliance / audit log.
Test restores on a schedule. A backup that has never been restored is only a hypothesis. Run a periodic restore drill (e.g., quarterly) into a throwaway Neon branch and confirm the RTO/RPO above actually hold.
Managed in IaC
Retention windows, GCS object versioning + lifecycle, and BigQuery snapshot schedules are declared in Pulumi (see Infrastructure) — reproducible and code-reviewed, never clicked in by hand.
CI/CD
CI/CD — GitHub Actions
All CI/CD pipelines run on GitHub Actions. Each workflow lives in its own .yml file under .github/workflows/.
ci.yml — Pull Request Quality Gate
Trigger:pull_request targeting dev (feature work), release/* (release candidates), or hotfix/* (production hotfixes) — see the branch strategy
All jobs run in parallel where possible. No PR may be merged unless all jobs pass.
Job
What it does
lint-js
pnpm biome check --error-on-warnings .
lint-py
ruff check backend/ + ruff format --check backend/
test-web-app
vitest run --coverage in ui/web-app
test-marketing
vitest run --coverage in ui/marketing
test-ios
vitest run --coverage in ui/ios
test-api
pytest --cov --cov-fail-under=80 in backend/api
test-worker
pytest --cov --cov-fail-under=80 in backend/worker
type-check
turbo run type-check
openapi-drift
Regenerate the ui/web-app API client from backend/api's OpenAPI schema and fail on any diff vs the committed client (keeps the typed client in lockstep with the API)
build
turbo run build (depends on all lint + test jobs)
Configure GitHub branch protection to require all CI jobs to pass before merging to dev. release/*and hotfix/* PRs into main additionally run the integration / E2E suite against a Neon Production branch — hotfix/* mirrors release/* (branches off the Production project, targets main, runs the same release suite). See Secure PHI GitOps.
Deployment workflows — one per deployable surface
Each deployable surface has its own workflow, triggered by a push to main with a path filter scoped to that surface. All follow the same pattern: authenticate via Workload Identity Federation (keyless), build + push a Docker image to Artifact Registry, then google-github-actions/deploy-cloudrun.
Every pull request gets an isolated full-stack environment — a Neon branch, a backend API on Cloud Run, and a frontend UI on Cloud Run — that is automatically provisioned and torn down.
See PR Preview Environments for the full specification, including workflow structure, secrets, and application code requirements.
Trigger the neon-to-bq Cloud Run Job (neon_to_bq.py) — incremental Neon → BigQuery raw_*
Run
dbt run --target prod (transforms in BigQuery)
Test
dbt test (BigQuery)
dbt uses the dbt-bigquery adapter and authenticates to BigQuery via Workload Identity Federation — it transforms entirely inside BigQuery and never connects to Neon/Postgres. The Neon → BigQuery move is the separate extract-load step, which runs as a scheduled Cloud Run Job before dbt run.
See Data Pipeline for details on the extract-load job and dbt models.
Workflow Organization
Each workflow is a separate file, following the code organization principle:
Every pull request automatically gets an isolated, full-stack environment — a dedicated applications UI (Vite + TanStack), Python API, and Neon database branch (with isolated auth) — that is torn down when the PR closes.
Architecture Overview
Component
Service
Naming Convention
Database
Neon Branch — off the Staging (non-PHI) project
pr-<number>
Backend API
GCP Cloud Run
api-pr-<number>
Frontend UI
GCP Cloud Run
web-app-pr-<number>
Orchestration
GitHub Actions + Neon actions + gcloud CLI
—
Because Better Auth stores its tables in Neon, each Neon branch automatically gets an isolated auth environment — the auth tables branch with the database, at zero extra infra. Pulumi is not required for PR environments; the Neon branch actions + gcloud run deploy handle ephemeral resources natively.
What's in a preview (and what isn't). A PR env deploys only the web-app (server), the api, and a Neon branch. The marketing site (public, PHI-free, no per-PR data) and the worker + Restate runtime (durable-execution infra, not branch-scoped) are not spun up per PR.
Previews are tailnet-only. The web-app-pr-<n> / api-pr-<n> services deploy with internal ingress (no public *.run.app) behind the shared internal load balancer, reachable at *.pr.internalonly from the company tailnet (Tailscale). Feature previews are non-PHI, but the tailnet keeps unreleased work private and is the same access plane as Coder. See Infrastructure › Tailnet Access.
GitHub Actions Secrets
Secret
Purpose
GCP_PROJECT_ID
Google Cloud Project ID
GCP_REGION
Target region (e.g., us-west1)
NEON_API_KEY
Neon API key for branch create/delete + JWKS registration
NEON_PROJECT_ID
Reference ID of the Neon project
GCP authentication uses GitHub OIDC + Workload Identity Federation via google-github-actions/auth. No long-lived GCP service-account JSON keys are required. Per-PR runtime secrets are minted in CI and materialized into GCP Secret Manager (see Secret Management).
Spin-Up Workflow — pr-env-up.yml
Trigger:pull_request on types [opened, synchronize, reopened], scoped to base: dev (branches: [dev]). Only PRs targeting dev — i.e. feature/* work — spin up a Staging preview. release/* / hotfix/* PRs (which target main) get no preview env; they verify in-boundary against a Neon Production branch instead. See Secure PHI GitOps.
Concurrency: Cancel in-progress runs for the same PR to avoid race conditions.
Job 1 — Database Provisioning (Neon)
Uses neondatabase/create-branch-action to create a branch scoped to the PR off the non-PHI Staging project — feature branches never fork production. See Secure PHI GitOps for the dual-project (Staging non-PHI / Production PHI) model:
Branch-scoped Better Auth config (JWKS URL registered against this branch via the Neon API)
Job 2 — Backend Deployment (Python API)
Needs: Job 1
Step
Details
Authenticate
google-github-actions/auth with Workload Identity Federation
Build + Push
Docker image → Artifact Registry
Migrations
Apply schema (Drizzle Kit, from the top-level schema/ dir) to the Neon branch; post the neondatabase/schema-diff-action diff as a PR comment (the eng review gate)
Deploy
gcloud run deploy api-pr-${{ github.event.number }}
Environment variables injected into Cloud Run:
Variable
Source
DATABASE_URL
Job 1 output (Neon branch)
BETTER_AUTH_JWKS_URL
Job 1 output (branch-scoped)
The service deploys with --ingress=internal-and-cloud-load-balancing (no public URL) and is registered as a serverless NEG + host rule api-pr-<n>.pr.internal on the internal ALB — reachable only on the tailnet (see Tailnet Access). Its internal hostname is passed to Job 3.
PR preview environments must never host real PHI. Feature branches fork the Staging (non-PHI) Neon project — which holds only masked / synthetic data — so a preview branch structurally cannot contain PHI. See Secure PHI GitOps.
Job 3 — Web App Deployment (Vite + TanStack server)
Needs: Job 1, Job 2
The web-app is a server, not a static bundle — it hosts Better Auth + Drizzle and connects to Neon at runtime. So it is built and deployed as a server container carrying both build-time (VITE_*) and runtime (DATABASE_URL / BETTER_AUTH_SECRET) config.
Step
Details
Authenticate
google-github-actions/auth with Workload Identity Federation
Build + Push
Docker image → Artifact Registry — VITE_* values passed as build args (inlined into the client bundle)
Deploy
gcloud run deploy web-app-pr-${{ github.event.number }} with --ingress=internal-and-cloud-load-balancing (server container, no public URL)
Publish to tailnet
Add a serverless NEG + URL-map host rule web-app-pr-<n>.pr.internal on the internal ALB — reachable only on the tailnet (Tailnet Access)
Build arguments (inlined at build time):
Variable
Source
VITE_API_URL
Job 2 output (API Cloud Run URL)
VITE_BETTER_AUTH_URL
Branch-scoped Better Auth base URL
Runtime environment variables injected into Cloud Run:
Variable
Source
DATABASE_URL
Job 1 output (the PR's Neon branch) — Better Auth + Drizzle connect here
BETTER_AUTH_SECRET
Per-PR secret minted in CI, materialized into Secret Manager
Build-time vs. runtime: values referenced from client-side code via VITE_* are inlined at build time — pass them as Docker build arguments (the marketing site's NEXT_PUBLIC_* vars behave identically). But because web-app is a server, its server-only secrets (DATABASE_URL, BETTER_AUTH_SECRET) are injected as runtime Cloud Run env vars, never baked into the image.
Job 4 — PR Comment
Needs: Job 3
Posts (or updates) a sticky comment with the preview URLs:
Deleting the branch also disposes of its isolated auth data and any per-PR secrets.
Application Code Requirements
Web app (Vite + TanStack server)
Dynamic API URLs. All client API calls use VITE_API_URL — never hardcode localhost or a staging domain.
Server container, not a static dist/. The web-app hosts Better Auth + Drizzle, so it ships as a server container that reads DATABASE_URL + BETTER_AUTH_SECRET at runtime (from the PR's Neon branch + the minted per-PR secret). VITE_* values are baked in as build args; server secrets stay runtime env vars.
Python (Backend)
Dynamic CORS. The CORS middleware accepts requests from the ephemeral web-app-pr-*.run.app domains via a strict, anchored regex:
[!WARNING]
Never use a wildcard (*) for CORS — sessions and tokens must be protected even in PR environments.
Database migrations. Run migrations (Drizzle Kit, from the top-level schema/ dir) against the Neon PR branch on startup or as a distinct pre-deploy step; the schema-diff-action comment is the human review gate for schema + RLS changes.
This is the branching + environment architecture that lets us keep developer velocity with database branching while enforcing strict HIPAA containment. It rests on three pillars: two isolated Neon projects (non-PHI vs PHI) with matching object-storage buckets (§6), an isolated compute boundary for the rare PHI-touching work, and automated Git ↔ Neon synchronization.
This decouples the coding-agent choice from HIPAA. Everyday development runs entirely against the non-PHI Staging project on developers' local machines, so any coding agent is fair game there — Claude Code, GitHub Copilot, Cursor, whatever — because no PHI ever reaches a laptop or a model. Only release/* / hotfix/* work touches real PHI, and it is confined to a hardened Coder workspace inside the GCP boundary, where tooling is restricted to an approved, in-boundary assistant. That confinement is what frees the everyday coding-agent choice from any compliance weight — the ~99% non-PHI case can use any assistant, while the specific in-boundary assistant for PHI work is a separate, still-open decision (WIP — the AI-Native Dev Stack effort).
1. Neon Project Configuration
Two separate Neon projects — separate projects, not two branches of one, so the PHI boundary is a hard connection-level boundary, not a policy convention.
Project A — Staging (non-PHI)
Purpose: durable sandbox for everyday development.
Data profile: sanitized, masked, or synthetic data only. Zero PHI.
Access: open to all developers from standard local IDEs (localhost).
Branching: CI creates an ephemeral Neon branch off this project's primary branch for every feature/* PR. This is the existing PR Preview Environment mechanism, now explicitly rooted in the non-PHI project.
Project B — Production (PHI)
Purpose: live application data and pre-release verification against real data shapes.
Data profile: real PHI.
Access: highly restricted. Database connections permitted only from:
Live GCP production application instances.
Authorized CI/CD runner identities (Workload Identity Federation; Neon IP Allow).
Isolated Coder workspaces running inside the secure GCP subnet.
Branching: CI creates ephemeral Neon branches off this project's primary branch only for release/* and hotfix/* PRs.
Staging (Project A)
Production (Project B)
Data
Masked / synthetic — no PHI
Real PHI
Branch trigger
feature/* → dev
release/*, hotfix/* → main
Who connects
Any dev, localhost
Prod app · CI runners · Coder-in-subnet only
Ephemeral branch
pr-<n>
release-<version>
This refines the earlier "copy-on-write from prod, then redact" model: feature work never branches from PHI at all — it branches from a separate masked project, so there is no window in which a preview branch holds real PHI. Production branches exist only for in-boundary release verification.
2. Compute & Network Boundary (GCP + Coder)
To prevent local PHI contamination during release troubleshooting:
Deploy Coder into a dedicated, private GCP subnet, reachable only over the company tailnet (Tailscale) — the same tailnet that gates previews / staging (see Infrastructure › Tailnet Access).
Configure VPC firewall rules so that only this subnet may reach the Neon Production project (Neon IP Allow / private networking). Nothing outside the subnet can open a connection to Project B.
The Coder workspace image enforces an approved, in-boundary coding assistant (e.g., Gemini Code Assist; the specific coding-agent choice is still WIP, tracked under the AI-Native Dev Stack effort — not blocked on it, since any approved in-boundary tool satisfies the boundary) — arbitrary local agents are not used against PHI.
Disable clipboard sharing and local file download/upload from the Coder workspace via policy.
All PHI-adjacent compute stays inside the GCP BAA boundary; PHI never lands on an endpoint device.
The boundary is the point: a developer debugging a release connects to the Neon Prod branch only from inside the Coder subnet, using in-boundary tooling, with exfiltration paths (clipboard, downloads) closed. Everyday work — the vast majority — happens on the non-PHI Staging project with no such constraints.
Tailscale needs no BAA here. The Coder session is the one place PHI could traverse the tailnet; Tailscale is WireGuard, end-to-end encrypted (its relays pass ciphertext only, never plaintext), so it qualifies as a HIPAA conduit (like an ISP) — no Tailscale BAA. Keep PHI plaintext off any relay/coordination hop. See Infrastructure › Tailnet Access.
3. GitHub Branch Strategy & Protection
Two long-lived trunks — main (production) and dev (staging / integration) — plus short-lived feature/*, release/*, and hotfix/* branches. Configure protection via Settings → Branches.
main (Production)
Require a pull request before merging: on.
Require approvals: 1–2, and Require review from Code Owners: ON — this is the gate that routes schema / RLS changes to the CTO (see §4).
Restrict who can push/merge: limited to the Release Managers / Lead Engineers team (this — not CODEOWNERS — is how "only release managers land main" is enforced).
Require status checks: Integration Tests, E2E Tests, Migration Checks.
Do not allow bypassing the above settings: on.
dev (Staging / Integration)
Require a pull request before merging: on.
Require approvals: 1 (peer review / quality gate). Require review from Code Owners: OFF — so schema changes flow freely into dev without the CTO gate.
Require status checks: Unit Tests, Staging DB Migration Checks.
release/* (Release Candidates)
Require a pull request before merging: on.
Require status checks: the full suite run against a Neon Production fork (real-data-shape verification).
hotfix/* (Production Hotfixes)
Require a pull request before merging: on.
Require status checks: the same full release suite, run against a Neon Production branch — a hotfix/*branches off Production and targets main, mirroring release/*. It exists to patch prod urgently without waiting on the normal release train.
After merge to main, the automated backmerge (main → dev) carries the fix downstream (Workflow C).
CODEOWNERS is path-based, not branch-based. The per-branch differences above (who approves dev vs who can land main) come from branch-protection rules + "restrict who can push," not from CODEOWNERS. CODEOWNERS decides which files require whose review on any PR — see §4.
4. CODEOWNERS Configuration
.github/CODEOWNERS enforces the quality gate by file path. Adapted to this monorepo — schema/migrations are Drizzle Kit (single migration history; the Python side reflects — no Prisma, no Supabase, no Alembic), so the paths differ from the common boilerplate:
# App code — peer + lead review/ui/ @your-org/frontend @your-org/lead-engineers/backend/ @your-org/backend @your-org/lead-engineers# CI/CD + infrastructure — DevOps review/.github/workflows/ @your-org/devops/infra/ @your-org/devops# Schema, migrations + RLS policies — the top-level schema/ dir (Drizzle Kit owns# the single migration history). Owner = the CTO for now (swap for a# @your-org/database-admins team as the org grows)./schema/ @your-org/cto# Analytics models (Neon → BigQuery)/dbt/ @your-org/data
The stock template ships /prisma/schema.prisma and /supabase/migrations/ lines — delete those. This stack has neither Prisma nor Supabase; DDL, migrations, and RLS live in Drizzle Kit under the monorepo (see Backend and Libraries).
Open schema commits on dev, CTO-gated into main. CODEOWNERS is a single repo-wide, path-based file — but "Require review from Code Owners" is a per-branch protection toggle. Turn it ON for main only and leave it OFF for dev. Then a schema / RLS change merges into dev on a normal peer approval (anyone), but reaching main — which only happens through a release/* PR — requires the CTO's Code-Owner approval. One CODEOWNERS entry, asymmetric enforcement: everyone evolves the schema upstream; only the CTO okays it for prod. (GitHub Rulesets express the same per-branch-pattern rule if you prefer them to classic branch protection.)
5. CI/CD Pipeline Workflows (GitHub Actions)
Four workflows orchestrate Neon and GitHub. GCP auth is Workload Identity Federation throughout (no long-lived keys — see PR Environments).
Workflow A — Feature Development (feature/* → dev)
Trigger: PR opened against dev.
Create a branch on the Neon Staging project named pr-<number> (neondatabase/create-branch-action).
Run Drizzle Kit migrations against the Staging branch; post the neondatabase/schema-diff-action comment as the review gate.
Run application tests.
Teardown: on PR merge/close, delete the Neon Staging branch.
Post-merge: run migrations on Staging main to update the baseline schema future forks branch from.
Trigger: PR opened against main (branch matches release/*orhotfix/*).
Create a branch on the Neon Production project named release-<version> (or hotfix-<id>).
Spin up secure CI compute (in-boundary runner) and connect to the Neon Prod branch.
Immediately execute pending Drizzle migrations.
Run integration / E2E tests against the migrated real-data-shape schema.
Intervention (if needed): a developer opens a Coder workspace in the secure subnet, connects to the Neon Prod branch to debug, and commits fixes directly to the release/* branch.
Teardown: on merge to main, delete the Neon Prod branch.
Workflow C — Automated Backmerge (main → dev)
Trigger: push / merge on main.
Attempt a fast-forward or clean merge of main into dev.
On success: push the updated dev.
On conflict: create sync-main-to-dev-<timestamp>, open a PR into dev, and assign the release PR's author to resolve.
Workflow D — Staging Data Refresh (Cron)
Trigger: weekly schedule (e.g. Sunday 02:00).
Take a logical dump of Neon Production — inside the boundary.
Pipe the dump through a HIPAA-capable masking / synthetic-data tool (e.g., Neosync — OSS, anonymization + synthetic; alternatives: Tonic, postgresql-anonymizer, Snaplet seed).
Restore the masked dump into the Neon Staging project, giving developers realistic data volumes and shapes with zero PHI.
Refresh the Staging blob base too (§6): regenerate synthetic blob fixtures into the Staging bucket for every object the masked dataset references, so masked rows resolve to masked blobs — no dangling keys, no PHI. Same in-boundary masking pass; only masked/synthetic bytes ever reach Staging.
The masking step is the compliance boundary of this workflow: it runs entirely in-boundary, and only masked output is ever written to Staging. Never restore an unmasked production dump into the Staging project, and never run the dump on a developer endpoint.
6. Object Storage (GCS) — same boundary, overlay reads
A Neon branch is only half a preview: its rows reference blobs (clinical-note attachments, uploaded documents) that must resolve to a matching, isolated store. So blobs follow the same PHI split as the database — per-class buckets plus an overlay (copy-on-write) read so a branch sees its own writes layered over a read-only base, and never mutates the base.
Buckets
Bucket
Data
Overlay base (read-through)
Writable by
Retention
…-uploads-prod (CMEK)
real PHI
— (authoritative)
prod app only
versioned / lifecycle
…-uploads-phi-preview
PHI, per-branch
→ …-uploads-prod (read-only)
release/* / hotfix/* CI + Coder-in-subnet
short TTL (~7d) + teardown
…-uploads-staging
masked / synthetic
— (base)
Workflow D refresh
rolling
…-uploads-nonphi-preview
masked / synthetic, per-branch
→ …-uploads-staging (read-only)
feature/* CI + any dev
short TTL + teardown
Each preview branch writes under its own prefix: …-preview/<branch>/<key>.
Overlay semantics (copy-on-write)
Readkey: try the preview bucket at <branch>/<key>; on miss, read the base bucket at <key>. The base is read-only — prod/staging blobs are never touched by a preview.
Writekey: always the preview bucket at <branch>/<key>. New uploads and overwrites land in the branch's own layer.
Deletekey: removes only the preview copy. (If a preview must hide a base object, write a zero-byte tombstone under <branch>/<key> that the read path treats as "not found" — rarely needed.)
Teardown: delete the <branch>/ prefix on PR close; the short-TTL lifecycle rule is the backstop.
The PHI-preview class deliberately reads through to the prod bucket — acceptable because that class is a first-class PHI environment (release/* / hotfix/*, Coder-subnet-only). The non-PHI class reads through to the masked Staging base and holds no IAM whatsoever on the prod bucket. That asymmetry is the whole safety story: only the already-contained PHI environment can ever see real blobs.
IAM is the write-barrier
Grant each preview identity objectViewer on its base bucket (read-only) and objectAdmin on its own preview bucket only; the non-PHI preview SA gets nothing on …-uploads-prod. So "the service can't edit prod" is not a code convention — a stray write to the base is IAM-denied. (Same WIF / least-privilege model as §2 and Secret Management.)
Store logical keys, not gs:// URIs
Persist a logical object key (e.g. tenant/<id>/note/<id>) in Postgres — never an absolute gs://bucket/… URI. The storage layer composes bucket + branch prefix from environment config at call time, so the same branched DB row resolves to the prod bucket in prod, the phi-preview overlay in a release branch, and the nonphi-preview overlay in a feature branch — for free, no row rewriting.
Transparency lives in the adapter, not in GCS
GCS has no native overlay / read-through / union-bucket setting — no bucket config says "fall back to bucket A on a miss" (Anywhere Cache caches one bucket; versioning/soft-delete are within a bucket; a fronting proxy is just infra you'd write). So the overlay lives in a thin BlobStorageService every consumer goes through:
get(key) # overlay read: preview/<branch>/key → base/key
put(key, data) # always preview/<branch>/key
delete(key) # preview copy only
signed_read_url(key) # resolve which bucket holds it, then sign THAT object
signed_write_url(key) # always sign the preview object
Consumers pass logical keys only and never learn which bucket answered. The environment class (prod | phi-preview | nonphi-preview), base bucket, write bucket, and branch prefix are injected as config — the same place the Neon branch DATABASE_URL is injected. In prod, write-bucket == base-bucket and there is no overlay, so one class covers every environment. Because a signed URL points at a specific object in a specific bucket, a read URL is a server-side exists-check-then-sign — you can't hand a client a fallback URL.
How it all fits
Concern
Mechanism
PHI never on a laptop / in a model
Local dev → Staging (non-PHI) project only
PHI debugging is possible but contained
Coder in a private GCP subnet (reached over the tailnet), in-boundary tooling, no clipboard/downloads
Feature branches can't leak PHI
Ephemeral branches come off the masked Staging project, never prod
Release tested against real shapes
release/* verifies on a Production Neon branch, in-boundary
Blobs follow the same PHI boundary
Per-class GCS buckets + overlay reads — branch layer over a read-only base; writes only to the preview bucket (§6)
Preview can't corrupt prod/staging blobs
IAM: read-only on the base, write only on the preview bucket; non-PHI preview has no prod-bucket access
Schema/RLS always reviewed
CODEOWNERS on the Drizzle path + Code-Owner review on main/dev
Coding-agent choice is unconstrained
Compliance weight lives only in the Coder boundary, not the everyday IDE
act is not in packages — it is installed at init_hook time via the official curl installer so the binary survives in /usr/local/bin across devbox shell restarts.
Environment Variables
Dev-time defaults for DATABASE_URL (points at the developer's own Neon dev branch by default), API_URL, OTEL_EXPORTER_OTLP_ENDPOINT (the local otel-collector process — see Observability), and DEVBOX_COREPACK_ENABLED (enables pnpm/yarn via Corepack).
Shell Init Hook
Activates direnv for bash (guarded for non-bash shells), sources NVM, and installs act via the official curl installer if not present:
Local development runs against a per-developer Neon dev branch, not a local database. Each developer gets a copy-on-write branch of the shared dev database, so schema and seed data match production shape without anyone running a Postgres server.
DATABASE_URL in devbox.json (and the git-ignored .env) points at the developer's own Neon branch. Hydrate secrets from Pulumi ESC — see Secret Management.
Schema, migrations, and RLS live in the top-level schema/ package, owned by Drizzle Kit; apply them to your branch with drizzle-kit push / migrate. The Python side reflects the schema — no Alembic. See Libraries.
Better Auth stores its tables in Neon, so your dev branch carries an isolated auth environment for free.
Offline fallback. When you need to work without network access, devbox run dev:db starts a local Postgres (via docker/devbox) and you point DATABASE_URL at localhost:5432. This is a fallback only — the Neon dev branch is the default and keeps local closest to production.
direnv — .envrc
eval "$(devbox generate direnv --print-envrc)"
This auto-activates the Devbox shell whenever a developer cds into the project directory. Committed to the repo.
Local OpenTelemetry Collector — receives OTLP on 4317/4318; the target of the api (and worker) OTEL_EXPORTER_OTLP_ENDPOINT. Same collector + otel-collector-config.yml documented in Observability.
caddy
caddy run --config Caddyfile --watch
Local TLS reverse proxy
Each process declares dependency ordering and readiness probes (HTTP/TCP health checks) so services start in the correct order.
One collector, one story. The OTel Collector that Observability describes for local development is the same service brought up here as the otel-collector process (docker-compose.otel.yml). The API's OTEL_EXPORTER_OTLP_ENDPOINT defaults to this local collector; in production the identical OTLP stream points at telemetry.googleapis.com.
There is no database process in the default stack — local dev talks to a per-developer Neon dev branch over the network. For offline work, bring up the local Postgres fallback separately with devbox run dev:db.
devbox run dev or process-compose up brings up the entire local stack in one command.
Caddy runs as a process-compose service and acts as the local TLS-terminating reverse proxy, routing traffic across all dev services behind a single HTTPS endpoint. It uses tls internal to auto-generate certificates via the Caddy local CA.
Trust the local CA — after the first caddy run, execute caddy trust once
to add Caddy's root certificate to your OS/browser trust store. Without this
step, browsers will show TLS warnings for the tls internal certificates.
Why default_bind tcp4/0.0.0.0?
The global default_bind tcp4/0.0.0.0 directive is required when running inside a Coder workspace. Coder's port-scanning infrastructure only discovers TCP4 sockets; without this directive Caddy binds to the dual-stack [::] address, which is invisible to Coder's port-forward scanner and prevents Coder Desktop from automatically detecting and forwarding the proxy ports.
Caddyfile
# Local dev reverse-proxy with auto-generated internal TLS certificates.# Requires DNS: local.example.com & *.local.example.com → 127.0.0.1# Also supports Coder Desktop via $CODER_WORKSPACE_NAME (e.g. myapp-local):# {CODER_WORKSPACE_NAME}.coder → landing# app.{CODER_WORKSPACE_NAME}.coder → app + API{ admin localhost:2020 http_port 8080 https_port 4443 # REQUIRED for Coder Desktop port scanning: Coder's port-scanner only detects # TCP4 sockets. Without this, Caddy binds to dual-stack [::] and the proxy # ports are invisible to Coder's port-forward infrastructure. default_bind tcp4/0.0.0.0}# Landing page – local.example.com → Next.js marketing dev serverlocal.example.com { tls internal reverse_proxy localhost:3000 { header_up Host localhost:3000 }}# App + API – app.local.example.comapp.local.example.com { tls internal # /api/* → FastAPI backend (path forwarded as-is) handle /api/* { reverse_proxy localhost:8000 } # Everything else → Vite + TanStack app handle { reverse_proxy localhost:5173 }}# Coder Desktop – landing ({$CODER_WORKSPACE_NAME}.coder)# Falls back to a no-op placeholder host when CODER_WORKSPACE_NAME is unset.{$CODER_WORKSPACE_NAME:coder-workspace-placeholder}.coder { tls internal reverse_proxy localhost:3000 { header_up Host localhost:3000 }}# Coder Desktop – app + API (app.{$CODER_WORKSPACE_NAME}.coder)app.{$CODER_WORKSPACE_NAME:coder-workspace-placeholder}.coder { tls internal # /api/* → FastAPI backend (path forwarded as-is) handle /api/* { reverse_proxy localhost:8000 } # Everything else → Vite + TanStack app handle { reverse_proxy localhost:5173 }}# Coder web port-forwarding catch-all ─────────────────────────────────────────# Coder's browser-based port-forwarding (https://<port>--<workspace>.coder.domain/)# terminates TLS at the Coder edge and forwards plain HTTP to the workspace port.# Named site blocks above won't match the Coder-generated Host header, so this# http:// catch-all handles those requests on port 8080 (the http_port).# Use URL: https://8080--main--<workspace>--<user>.coder.<domain>/http:// { handle /api/* { reverse_proxy localhost:8000 } handle { reverse_proxy localhost:5173 }}
Replace example.com with your project's actual domain. The Coder Desktop virtual hosts work without any DNS changes — Coder Desktop resolves *.coder locally.
pnpm turbo run test --filter=[HEAD^1] (each package runs vitest run)
test-py
pytest --cov --cov-fail-under=80 in bothbackend/api and backend/worker (e.g. for d in backend/api backend/worker; do (cd "$d" && uv run pytest --cov --cov-fail-under=80); done)
Docker-in-Docker: envbuilder does not support privileged/init. on-create.sh installs Docker CE via the official convenience installer (get.docker.com) and starts dockerd; this is skipped in Codespaces where Docker is already provided. In Coder workspaces the Docker socket (/var/run/docker.sock) should be mounted from the host via the workspace template.
Example on-create.sh
#!/usr/bin/env bash# .devcontainer/on-create.sh# Runs as the devcontainer user (root) during onCreateCommand.# Idempotent — safe to re-run.set -euo pipefailIS_CODESPACES="${CODESPACES:-}"# ── 0. Ensure Docker is available early for lifecycle commands ───────────────# Codespaces already provides Docker; avoid trying to start nested dockerd.if ! command -v docker &>/dev/null; then if [ -n "$IS_CODESPACES" ]; then echo "WARNING: docker CLI is missing in Codespaces; skipping Docker bootstrap" else echo "→ Installing Docker CE..." curl -fsSL https://get.docker.com | sh fifiif ! docker info &>/dev/null 2>&1; then if [ -n "$IS_CODESPACES" ]; then echo "WARNING: Docker daemon not reachable in Codespaces during on-create; continuing" elif command -v dockerd &>/dev/null; then echo "→ Starting dockerd..." dockerd \ --host=unix:///var/run/docker.sock \ --data-root=/var/lib/docker \ >/tmp/dockerd.log 2>&1 & for i in $(seq 1 30); do docker info &>/dev/null 2>&1 && break sleep 1 done if docker info &>/dev/null 2>&1; then echo "→ Docker daemon ready" else echo "WARNING: dockerd did not become ready within 30s (see /tmp/dockerd.log)" fi fifi# ── 1. Initialize git submodules ──────────────────────────────────────────────# NOTE: This runs before the Coder agent starts, so git credential helpers are# not yet available. Private submodules requiring HTTPS auth may fail here.# We make this non-fatal; the postStartCommand (or manual re-run) can complete# it once the agent is running and git auth forwarding is active.echo "→ Updating git submodules..."git -C "$(dirname "$0")/.." submodule update --init --recursive \ || echo "WARNING: submodule update failed (git credentials not yet available — re-run on-create.sh once the workspace is ready)"# ── 2. Ensure zsh is installed ────────────────────────────────────────────────if ! command -v zsh &>/dev/null; then echo "→ Installing zsh..." apt-get update -qq && apt-get install -y zshfi# ── 2b. Ensure tmux is installed (apt, not nix, for host-OS integration) ─────if ! command -v tmux &>/dev/null; then echo "→ Installing tmux..." apt-get update -qq && apt-get install -y tmuxfi# ── 3. Configure nix to work without sandboxing ──────────────────────────────# Most Docker environments (even privileged) can't run the nix build sandbox# because they lack user-namespace support. Disabling sandbox + syscall filter# allows nix/devbox to build and fetch packages normally.mkdir -p /etc/nixif [ ! -f /etc/nix/nix.conf ] || ! grep -q 'sandbox = false' /etc/nix/nix.conf; then cat >> /etc/nix/nix.conf <<'EOF'sandbox = falsefilter-syscalls = falseEOFfi# ── 4. Install nix (single-user, no daemon) if needed ────────────────────────# The Determinate Systems installer handles Docker containers reliably.if ! command -v nix &>/dev/null; then echo "→ Installing nix..." curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix \ | sh -s -- install linux \ --init none \ --no-confirm \ --extra-conf "sandbox = false" \ --extra-conf "filter-syscalls = false"fi# Source the nix profile for the remainder of this scriptif [ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]; then # shellcheck disable=SC1091 . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.shfi# ── 5. Start the nix daemon ──────────────────────────────────────────────────# The Determinate Systems installer does not start the daemon automatically# (--init none). We start it here for this shell, and rely on postStartCommand# in devcontainer.json to restart it on subsequent workspace restarts./nix/var/nix/profiles/default/bin/nix-daemon --daemon &>/tmp/nix-daemon.log &NIX_DAEMON_PID=$!echo "→ nix-daemon started (pid $NIX_DAEMON_PID)"sleep 3 # wait for the daemon socket to be ready# ── 6. Install devbox if needed ───────────────────────────────────────────────if ! command -v devbox &>/dev/null; then echo "→ Installing devbox..." # -f = non-interactive (skip the "Install to /usr/local/bin?" prompt) curl -fsSL https://get.jetify.com/devbox | bash -s -- -ffi# ── 7. Install project packages ───────────────────────────────────────────────echo "→ Running devbox install..."devbox install# ── 8. Ensure shell init is set up for both bash and zsh ─────────────────────add_to_shell() { local marker="$1" text="$2" file="$3" touch "$file" grep -qF "$marker" "$file" || printf '\n%s\n' "$text" >> "$file"}# Source nix in all shells (devbox adds its own wrapper around this too)NIX_INIT='. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh 2>/dev/null || true'add_to_shell 'nix-daemon.sh' "$NIX_INIT" "$HOME/.bashrc"add_to_shell 'nix-daemon.sh' "$NIX_INIT" "$HOME/.zshrc"# devbox adds itself to PATH via its own init; make sure devbox shim is reachableDEVBOX_PATH_INIT='export PATH="${HOME}/.local/share/devbox/global/default/.devbox/nix/profile/default/bin:${PATH}"'add_to_shell 'devbox' "$DEVBOX_PATH_INIT" "$HOME/.zshrc"add_to_shell 'devbox' "$DEVBOX_PATH_INIT" "$HOME/.bashrc"echo "✓ on-create complete"
Runs on every container start (including restarts). Keeps idempotent tasks that don't survive a stop/start cycle:
Restart nix-daemon (no systemd in devcontainers; daemon doesn't survive stop/start)
Wait for the Docker daemon to be reachable (timeout configurable via DOCKER_WAIT_SECONDS) — Docker is needed for act and the optional local Postgres fallback
Example post-start.sh
#!/usr/bin/env bash# .devcontainer/post-start.sh# Runs on every container start. Keeps lightweight tasks idempotent.set -euo pipefail# Derive repo root dynamically to work across different workspace layouts.ROOT="${ROOT:-${WORKSPACE_FOLDER:-}}"if [ -z "${ROOT}" ]; then # Assume this script lives in .devcontainer/ under the repo root. SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd -P)" ROOT="$(cd "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd -P)"fi# Final fallback to the current directory if derivation failed for some reason.ROOT="${ROOT:-${PWD}}"LOG_FILE="/tmp/postStart.log"DOCKER_WAIT_SECONDS="${DOCKER_WAIT_SECONDS:-60}"log() { printf '%s\n' "$*" | tee -a "$LOG_FILE"}# Restart nix-daemon for this container session (no systemd in devcontainers).if [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then /nix/var/nix/profiles/default/bin/nix-daemon --daemon &>/tmp/nix-daemon.log & nix_daemon_pid=$! sleep 2 if ! kill -0 "$nix_daemon_pid" 2>/dev/null; then log "post-start: failed to start nix-daemon; see /tmp/nix-daemon.log" exit 1 fi # Verify daemon is responsive if command -v nix >/dev/null 2>&1; then for _ in {1..5}; do nix store ping >/dev/null 2>&1 && break sleep 1 done fifi# Wait for Docker to be reachable (needed by act and the optional local# Postgres fallback). Local dev otherwise talks to a remote Neon dev branch,# so there are no database images to pre-pull.log "post-start: waiting for Docker daemon (timeout ${DOCKER_WAIT_SECONDS}s)..."for _ in $(seq 1 "$DOCKER_WAIT_SECONDS"); do if docker info >/dev/null 2>&1; then log "post-start: Docker is ready" break fi sleep 1done
Based on jetpackio/devbox:latest. Pre-populates the Nix store and optimizes layer size:
FROM jetpackio/devbox:latestCOPY devbox.json devbox.lock ./RUN devbox run -- echo "Installed Packages."RUN nix-store --gc && nix-store --optimiseCMD ["devbox", "shell"]
act — Local CI Testing
act runs GitHub Actions workflows locally inside Docker containers, giving you fast CI feedback without a push to GitHub.
Why act?
Without act
With act
Push → wait for GitHub Actions queue
Run locally in seconds
Expensive feedback loop for CI changes
Iterate on workflow YAML offline
No offline support
Works with no internet after images are pulled
Installation
act is included in the Devbox package list and is available automatically inside the Devbox shell. No separate install step is required.
Quick-start
# Run the full PR quality-gate workflow (pull_request trigger)devbox run ci:local# Equivalent manual invocationact pull_request --container-architecture linux/amd64 -W .github/workflows/ci.yml# Run a single jobact pull_request -j lint-js -W .github/workflows/ci.yml# List all available jobs across all workflowsact --list
Always pass --container-architecture linux/amd64 on Apple Silicon (M-series) Macs. The Foundation CI runner images are built for linux/amd64; without this flag act will pull an incompatible arm64 image and jobs will fail.
Supplying secrets
act does not have access to GitHub repository secrets. Create a local secrets file (never commit it):
# .secrets ← add to .gitignoreDATABASE_URL=...NEON_API_KEY=...GCP_PROJECT_ID=...
Alternatively, supply individual secrets via --secret NAME=VALUE flags.
Runner image
act defaults to a minimal node:16-buster-slim image. For workflows that rely on tools pre-installed on GitHub-hosted runners (e.g., docker, python), choose a fuller image:
# Medium image (~500 MB) — recommended for the Foundation CI workflowact pull_request -P ubuntu-latest=catthehacker/ubuntu:act-22.04 \ --container-architecture linux/amd64 \ -W .github/workflows/ci.yml
Persist your preferred image in .actrc at the repo root (add to .gitignore):
.actrc is personal — it contains machine-specific flags and should be added to .gitignore. Shared workflow-level defaults belong in the workflow YAML itself.
Dependency Convention
Dependency Type
Install Method
System CLIs & runtimes
devbox.json packages (nixpkgs)
Python packages
uv sync (via devbox run setup)
Node.js packages
pnpm install (via devbox run setup)
Never install tools globally on the host machine. The Devbox shell (auto-activated by direnv) is the single entry point for all tooling.
Code Organization
Code Organization — File Modularity
Multiple developers and AI agents work on separate issues in parallel. To minimise merge conflicts and keep pull requests small and reviewable, the architecture enforces a strict "one concern per file" rule across every layer.
General Principles
One component / function / model per file
Each React component, FastAPI router, Pydantic model, durable job, and utility function lives in its own file. Prefer many small files over a few large ones.
Barrel files for public API only
index.ts (TypeScript) and __init__.py (Python) files re-export the module's public surface. Implementation stays in dedicated files beside the index. This means two developers can add new items to the same module without touching the same lines.
Co-locate tests appropriately
TypeScript/React: Test files live next to source (Button.tsx → Button.test.tsx).
Python: Tests go in a tests/ directory with filenames matching source modules.
Flat over nested
Two directory levels deep is fine. Five hinders discoverability. Avoid unnecessary nesting.
Frontend — ui/web-app & ui/components
ui/web-app is the applications UI; ui/components is a shared component/library workspace (one of the ui/* shared packages — see Monorepo) that houses reusable presentational components consumed by ui/web-app. The same one-concern-per-file rule applies to both.
backend/api is the FastAPI HTTP service; backend/worker holds the Restate durable-execution handlers. The same one-concern-per-file rule applies to both.
Tables, RLS, and migrations live in the top-level schema/ package — the single source of truth that Drizzle Kit owns and the Python api reflects (no Alembic). One concern per file:
tables/patient.ts, tables/appointment.ts — one table per file
tables.ts / schema.ts with every table
Generated SQL committed under migrations/
Hand-edited or duplicated migration histories
Authorization policies — policies/ (Cerbos)
Cerbos policy-as-code lives in a version-controlled, top-level policies/ directory (Cerbos YAML), loaded by the self-hosted Cerbos PDP (see Backend). One resource per file:
✅ Prefer
❌ Avoid
policies/patient.yaml, policies/appointment.yaml — one resource policy per file
policies.yaml with every resource's rules
Infrastructure & Config
One Pulumi resource group per file in infra/ (e.g., gcp.ts, neon.ts, cloudflare.ts).
One dbt model per .sql file (dbt enforces this).
One GitHub Actions workflow per .yml file.
Why This Matters for AI-Assisted Development
When AI coding agents (Copilot, Copilot Workspace, or third-party agents) work on issues concurrently, each agent typically modifies a small set of files. If unrelated features share a file, the agents' branches produce conflicting diffs on the same file, requiring manual resolution.
Granular files mean:
Zero conflicts. Two agents adding two different components create two different files.
Smaller PRs. Each change touches only the files it needs to.
Easier reviews. Human and automated reviewers see focused, relevant diffs.
Trivial reverts. Removing a feature is as simple as deleting its file(s).
When in doubt, split it out.
Observability
Observability — OpenTelemetry → GCP-native
The observability stack uses OpenTelemetry for instrumentation and GCP-native backends for storage and visualization: Cloud Logging (+ Log Analytics), Cloud Trace, Cloud Monitoring, and Error Reporting. Telemetry stays inside the existing Google BAA — no second vendor boundary, no standing Elasticsearch cluster.
This replaces the older ELK / Elastic Cloud design. OTel is the hedge: the backend is exporter-agnostic, so switching or adding a backend later (a SIEM, Datadog, self-hosted Grafana) is exporter config, not a rewrite. Revisit only if a real Kibana-search or SIEM need emerges. Cost is roughly free at launch volumes (Cloud Logging: 50 GiB/project/mo ingest free, then ~$0.50/GiB).
API OTel Setup — backend/api
Dependencies (pyproject.toml)
opentelemetry-sdk
opentelemetry-instrumentation-fastapi
opentelemetry-exporter-otlp
Initialization (main.py)
Initialize the OTel tracer + meter on application startup.
Auto-instrument FastAPI with opentelemetry-instrumentation-fastapi.
The OTLP endpoint is configured via OTEL_EXPORTER_OTLP_ENDPOINT.
In production, OTLP is exported to GCP's managed endpoint (telemetry.googleapis.com) → Cloud Trace / Cloud Monitoring. In development, it points at the local OTel Collector.
Cloud Run automatically ships stdout/stderr to Cloud Logging. Emit structured JSON logs so they land in Log Analytics with queryable fields.
The Restate durable-execution worker (backend/worker) is instrumented the same way — same OTLP exporter and OTEL_EXPORTER_OTLP_ENDPOINT — so its traces and metrics land in the same GCP backends.
PHI Scrub Filter
CRITICAL: Patient health information must never appear in logs — or in spans.
A PHIScrubFilter stub class is included in the Python backend services (backend/api and backend/worker):
import loggingclass PHIScrubFilter(logging.Filter): def filter(self, record): raise NotImplementedError( "Implement PHI scrubbing before production use" )
This filter is attached to the root logger in each service (backend/apiandbackend/worker) and will intentionally fail if any log message is emitted before the scrubbing logic is implemented. (This matches the PHIScrubFilter control in HIPAA Compliance, which covers both backends.)
TODO before production:
Implement regex-based scrubbing for:
Medical Record Numbers (MRNs)
Dates of Birth (DOB patterns)
Patient names
Test the filter with representative log data
Verify no PHI appears in Cloud Logging, Cloud Trace spans, or Cloud Build logs
Local Development Stack — docker-compose.otel.yml
For local development, run a lightweight OTel Collector — no Elasticsearch/Kibana required:
Service
Image
Port
OTel Collector
otel/opentelemetry-collector-contrib:latest
4317 (gRPC), 4318 (HTTP)
Jaeger (optional local trace viewer)
jaegertracing/all-in-one:latest
16686 (UI)
The collector receives OTLP on 4317/4318 and, in dev, exports to the console and/or a local Jaeger UI. In production the same OTLP stream is pointed at telemetry.googleapis.com.
One collector, one story. This is the same collector the local dev stack brings up as the otel-collector process — Developer Tooling's process-compose runs docker compose -f docker-compose.otel.yml up, and the api/workerOTEL_EXPORTER_OTLP_ENDPOINT defaults to it (http://localhost:4317).
Use Cloud Monitoring dashboards first. If Grafana-grade dashboards are needed, self-host Grafana on Cloud Run against Cloud Monitoring / Managed Prometheus data sources — GCP has no first-party managed Grafana, and Grafana Cloud SaaS would add a third-party boundary.
Scrub PHI from logs and spans. Cloud Logging and Cloud Trace are inside the Google BAA, but PHI still must not be written there.
Alerting / On-Call
Alerting is GCP-native, matching the rest of the observability stack — no separate paging vendor at launch:
Cloud Monitoring alert policies fire on metric thresholds, log-based metrics, and uptime checks.
SLOs (Cloud Monitoring service-level objectives with burn-rate alerts) drive the signal — page on error-budget burn, not on every transient blip.
Notification channels: alerts route to Microsoft Teams (via an incoming-webhook channel) and email. Teams is the primary real-time surface; email is the durable backstop.
Alert payloads are PHI-free — send resource names, metric values, and trace IDs only, never PHI. Teams and email are outside the trust zone.
Dedicated paging (PagerDuty / Opsgenie) is deferred. Cloud Monitoring → Teams + email is sufficient for a small team. Add a paging tool with on-call rotations, escalation policies, and SMS/phone paging as the team grows and 24/7 coverage is needed.
Error Tracking
Error tracking is split by tier, each staying inside an existing boundary:
Backends (backend/api, backend/worker) → GCP Error Reporting (part of Cloud Operations; groups exceptions from Cloud Run logs, in-boundary under the Google BAA).
Clients (ui/web-app, ui/marketing, ui/ios) → PostHog error tracking — already the BAA-covered analytics/replay vendor, so it covers web + Expo/React Native with no extra boundary.
Escalation:Sentry if richer release-health / source-map grouping is needed later (adds a vendor boundary + BAA).
Error payloads can carry PHI — exception messages, request context, and breadcrumbs. Scrub PHI before it reaches Error Reporting or PostHog, exactly as for logs and spans, and enable PostHog's masking on captured errors.
LLM Observability & Evals
LLM calls (Gemini via Vertex) are traced and evaluated in PostHog (LLM analytics — prompt/response capture, latency + cost, and eval scoring), consolidating on the vendor already inside the BAA.
Escalation: self-hosted Langfuse on Cloud Run (in-boundary) if deeper trace trees or dataset-based eval workflows are needed.
Prompts and completions on clinical tasks are PHI. Keep LLM traces inside the boundary (the PostHog BAA), mask/scrub PHI in captured prompts and outputs, and never key a trace on PHI. See HIPAA Compliance.
HIPAA Compliance
HIPAA Compliance Scaffold
HIPAA compliance is scaffolded into the architecture from day one. This is not an afterthought — every vendor choice, logging decision, and data flow is designed with the Security Rule and Privacy Rule in mind.
The Trust Zone (organizing principle)
Define one trust zone. A vendor may touch PHI only if it is inside it — i.e. we hold a signed BAA and the service is configured for it.
PHI at rest lives only in Neon, GCS, Vertex AI, and WellSky, plus the governed Microsoft 365 plane.
PHI in motion only crosses services that are inside the zone.
Anything outside the zone receives references (opaque IDs), never PHI.
The governed Microsoft 365 plane holds PHI in practice. The workforce plane (Microsoft 365 — Teams, SharePoint, OneDrive, Exchange) will contain PHI (e.g. Excel/Word files with patient info), so it is treated as a governed plane under a Microsoft BAA, with Purview labeling/DLP + Intune device control + Entra Conditional Access — the control is policy about where PHI lives and who accesses it, not pretending PHI stays out. By contrast, Notion is NOT HIPAA-covered (no BAA) — no PHI of any kind in Notion. See Business Operations.
🚫 PHI-restricted services (opaque IDs by default)
Default posture: opaque identifiers only. The services below receive opaque IDs — never names, MRNs, DOBs, or clinical notes. A few carry a specific, stated carve-out (Restate holds KMS-ciphertext; PostHog is BAA-covered for behavioral events) — spelled out in their row. Everywhere else, code fetches PHI from Neon inside the trust zone at the point of use.
Service
Role
Rule
Durable execution — Restate Cloud (managed)
Async / agent orchestration
No PHI shared with Restate Cloud (initial posture). Any PHI that must be journaled is encrypted at the serde boundary inside the Cloud Run handler (GCP-KMS-backed encrypting Serde), so Restate Cloud's journal and virtual-object state hold ciphertext only; the primary entity identifiers are opaque, short-lived IDs, so keys, trace IDs, and call structure stay PHI-free and non-correlatable. Restate Cloud is an encrypted conduit, so no BAA is required. Keep object keys, state-key names, and error messages PHI-free. Simple jobs pass opaque short-lived IDs and re-fetch PHI from Neon in-zone.
BAA-covered carve-out (PostHog Cloud, Boost+ platform package, self-serve BAA): behavioral events are permitted under the BAA, but identify users by opaque IDs, put no PHI in flag keys, event names, or property keys, enable PHI masking on session replay, and scrub PHI from captured error payloads and LLM prompt/response traces.
Sanity
Marketing / CMS content
Marketing + in-app copy only. Never store PHI.
Neon Data API (PostgREST)
Alt data access
Outside the HIPAA boundary — no PHI path may use it. Use a direct Postgres connection for anything touching PHI.
Telnyx SMS bodies
Notifications / 2FA
Keep PHI out of message text (SMS is not end-to-end encrypted on the carrier network). Clinical detail stays on the voice channel.
Notion (docs & wiki — incl. the architecture plan itself)
Planning & documentation
No BAA — Notion does not sign one; it is not HIPAA-covered.Zero PHI of any kind — none in pages, comments, tickets, screenshots, pasted logs, or examples. Synthetic / de-identified data only.
Never paste PHI into outreach tools, chat, tickets, wikis, or meeting notes.
"In the boundary" ≠ "OK to log PHI." Cloud Logging, Cloud Trace, and OTel spans are covered by the Google BAA, but PHI must still be scrubbed before it is written there — see Observability.
Vendor BAAs Required
Every vendor that could handle Protected Health Information (PHI) requires a Business Associate Agreement:
Vendor
Plan / Config
BAA
GCP (Cloud Run, BigQuery, GCS, Secret Manager)
Standard
✅ Google BAA — verify project coverage
Neon
Scale plan; set hipaa:true per project (irreversible; audit_log_level=hipaa)
✅ Self-serve BAA
Vertex AI (Gemini)
Reasoning, embeddings, voice; pin to a US region
✅ Under the Google BAA — request ZDR / abuse-logging opt-out. Never use ai.google.dev. See AI Tier
Transactional (product) email; us-west-2, co-located with Neon
✅ Free self-serve AWS BAA — no PHI in email bodies
Do not store or process PHI with any vendor until the BAA is signed and confirmed.
Durable execution — Restate Cloud (managed, locked). Restate runs on Restate Cloud (managed, serverless, us region) driving Cloud Run handlers. The initial posture shares no PHI with Restate Cloud — there is no Restate BAA. Because Restate journals ctx.run results, call args/results, awakeable payloads, and virtual-object state (ctx.set), any PHI that must be journaled is encrypted at the serde boundary inside the handler via a GCP-KMS-backed encrypting Serde — Restate Cloud's journal and state hold ciphertext only, never plaintext. The serde does not encrypt keys, so the primary entity identifiers are opaque, short-lived IDs (not stable DB primary keys): object keys, state-key names, error messages, and trace IDs stay PHI-free and non-correlatable. Restate Cloud thus handles only ciphertext + PHI-free metadata, so it is treated as an encrypted conduit and no vendor BAA is required. Sharing PHI with Restate later would require a signed BAA first.
Authorization — Cerbos (self-hosted, locked). Cerbos is a stateless policy-decision-point running self-hosted in GCP (Cloud Run / sidecar container). It evaluates policy-as-code (YAML + CEL) over opaque IDs, roles, and attributes only — its inputs and decisions are PHI-free, so no vendor BAA is required. It holds no data at rest. Neon RLS remains the enforcement floor; Cerbos's Query Plan API emits SQL WHERE filters that pair with RLS. See Backend.
Technical Controls
Control
Status
Details
PHIScrubFilter
Stub (must implement)
Attached to root logger; blocks all logs until PHI scrubbing is implemented
Secrets
Must configure
GCP Secret Manager (runtime) + Pulumi ESC (declarative). No DB-resident vault. See Secret Management
Neon RLS
Must enable
Row-Level Security on all tables containing patient data; schema + policies defined in the top-level schema/ dir (Drizzle Kit); policies key on auth.user_id() via pg_session_jwt (Better Auth JWTs)
BigQuery access controls
Must review
Restrict dataset access to authorized service accounts; de-identify PHI in the warehouse or keep the dataset inside the BAA
Cloud Run service account
Must configure
Least-privilege principle
Cloud Run env vars
Must review
No PHI in build logs or container env
Audit logging
Must enable
Neon audit_log_level=hipaa; GCP Cloud Audit Logs
Data Handling
Storage
Neon: storage + compute billed as usage on the Scale plan; monitor branch/compute usage.
BigQuery: first 10 GB storage free; ~$0.02/GB/month after. Review partitioning at 100 GB.
GCS: object storage for uploads/files — add when the first upload feature lands.
Cross-Cloud Data Transfer
The analytics sync flows Neon (OLTP, source of truth) → BigQuery (OLAP), incrementally (WHERE updated_at >) to minimize egress across the GCP ↔ AWS boundary (Neon runs in AWS us-west-2, same metro as Cloud Run us-west1).
Full-table cross-cloud scans are prohibited — they incur significant egress cost and are a compliance risk.
PHI in Logs
PHI must never appear in:
stdout / stderr on Cloud Run
Cloud Logging / Log Analytics
Cloud Build / Cloud Run logs
GitHub Actions logs
Any observability pipeline (Cloud Trace spans, OTel)
Enforced by the PHIScrubFilter in the Python backend services (backend/api and backend/worker) and manually audited in frontend code.
Compliance Checklist
A hipaa-checklist.md file is included in every project with the full actionable checklist. Teams should review and check off each item before handling production PHI.
These are the standard libraries for all Foundation projects. Do not substitute without explicit justification and team approval.
Python
Library
Purpose
fastapi
HTTP API framework
uvicorn[standard]
ASGI server
pydantic
Data validation and serialization
pydantic-settings
Settings management from environment variables
sqlalchemy
Postgres query layer / ORM (Neon)
asyncpg
Async Postgres driver (Neon)
pyjwt[crypto]
Validate Better Auth JWTs (Neon RLS / auth)
polars
DataFrame / data transformation
fire
CLI interface generation
google-cloud-bigquery
Google BigQuery client (analytics sync)
google-genai
Google Gen AI SDK for Gemini — reasoning, embeddings, and voice, configured for Vertex (vertexai=True, project + location); never ai.google.dev. Used by backend/worker + embedding jobs (see AI Tier)
restate-sdk
Durable-execution client (Restate, 1.0) — ASGI/Hypercorn, some glue vs FastAPI-native
cerbos
Authorization PDP client — checks + Query Plan API (SQL filters that pair with Neon RLS)
Python Dev / Test
Library
Purpose
ruff
Linting + formatting
pytest
Test runner
pytest-cov
Coverage reporting
httpx
Async HTTP client (FastAPI test client)
JavaScript / TypeScript
Library
Purpose
vite
Build tool for the application UIs
@tanstack/react-router
Application routing
@tanstack/react-query
Application data fetching / server state
openapi-typescript + openapi-fetch
Typed API client generated from the FastAPI OpenAPI schema (the frontend ↔ backend contract)
better-auth
Authentication (hosted by the applications-UI server)
drizzle-orm + pg
Typed data access — direct Postgres → Neon, with RLS
drizzle-kit
Schema migrations + DDL for Neon — schema, migrations, and RLS policies live in the top-level schema/ package (owns tables and RLS; single migration history — the Python side reflects, no Alembic)
next
Marketing site framework (Next.js 14, App Router)
expo + expo-router
Mobile framework (React Native)
posthog-js
Product analytics + session replay on ui/web-app (client; masking on)
posthog-node
Server-side PostHog for flags (local eval) + events — ui/web-app server and ui/marketing
posthog-react-native
Product analytics + session replay on ui/ios (masking on)
Auth is Better Auth on the applications-UI server (the Vite + TanStack ui/web-app), storing its tables in Neon. The app server needs DATABASE_URL + BETTER_AUTH_SECRET; the FastAPI backend validates the issued JWTs via BETTER_AUTH_JWKS_URL. There are no Supabase Auth keys. See Frontend and Backend.
Durable-execution engine: Restate Cloud (managed). The Restate Cloud environment endpoint plus its request-signing / identity keys (RESTATE_*) live in GCP Secret Manager. PHI never travels as plaintext through Restate Cloud — it is encrypted at the serde boundary inside the handler (GCP-KMS-backed encrypting Serde), so the journal and virtual-object state hold ciphertext only (an encrypted conduit, no BAA required). Never commit secrets. See Backend.
GCP authentication uses Workload Identity Federation — no GCP_CREDENTIALS JSON key secret is needed. The per-PR Neon branch DATABASE_URL and branch-scoped Better Auth config are minted in CI and destroyed with the branch. See PR Preview Environments.
Storage Locations
Cloud Run Environment Variables
Used for the web-app, marketing, api, and worker service configuration. Non-secret values are set directly as Cloud Run env vars; secrets are stored in GCP Secret Manager and mounted at deploy time. Note:VITE_* / NEXT_PUBLIC_* / EXPO_PUBLIC_* variables used in client-side code are inlined at build time and must be passed as Docker build arguments — they cannot be changed via runtime Cloud Run env vars.
Pulumi ESC + GCP Secret Manager
Pulumi ESC is the declarative source of truth for every environment's config + secrets; it materializes them into GCP Secret Manager, the IAM-scoped runtime store (inside the Google BAA). Cloud Run mounts secrets from Secret Manager at deploy time. There is no database-resident vault — Neon does not support supabase_vault / pgsodium, and a DB-resident secret would be copied into every preview branch. See Secret Management.
GitHub Actions Variables / Secrets
Variables (non-secret): Used in CI/CD workflows for configuration like project IDs, regions, and dataset names.
Secrets: Used for deployment credentials (NEON_API_KEY) and service connection strings minted per-PR.
.env.example Files
Each service must have a .env.example file listing all required variables with placeholder values:
This architecture isolates the marketing frontend from the core application. By treating the marketing site as a standalone entity, marketing teams and frontend developers can rapidly iterate on copy, design, and A/B tests without triggering the heavy, complex deployment cycles of the primary application stack.
One BAA-covered vendor spanning product analytics + feature flags + experiments + session replay + client error tracking + LLM observability & evals. This page covers the marketing site only (server-side posthog-node); the product apps are covered elsewhere — see the note below.
SEO-optimized page rendering (the ui/marketing service)
Sanity never stores PHI — it holds marketing and in-app copy only. PostHog is BAA-covered (PostHog Cloud, Boost+ platform package), so it may hold product/behavioral data under the BAA; the discipline is to mask PHI in session replay and keep no PHI in flag keys, event names, or property keys. On the public marketing site, visitors are anonymous and identified by opaque IDs only. See HIPAA Compliance.
1. Core Architecture: Strict Separation of Concerns
The Marketing Frontend (Next.js 14 on Cloud Run)
A lightweight Next.js 14 application whose sole responsibility is rendering fast, SEO-optimized marketing pages. It lives in the monorepo as the ui/marketing workspace and deploys as its own GCP Cloud Run service — separate from the applications frontend (ui/web-app) and the backend API. A CSS tweak or hero-copy change ships through its own deploy workflow (GitHub Actions → Artifact Registry → Cloud Run) without touching the application pipeline.
Marketing is a Cloud Run service, not a Vercel deployment. Every frontend in Foundation — applications (ui/web-app, Vite + TanStack) and marketing (ui/marketing, Next.js 14) — runs on Cloud Run. The two are independent services, not a single deployment.
The Core Application
The primary product stack with its own rigorous CI/CD pipeline (GitHub Actions → GCP Cloud Run). It has no runtime dependency on the marketing frontend.
The Shared Content API (Sanity.io)
Although the two deployments are completely independent, Sanity acts as a universal Content Lake for both:
Marketing site — fetches page copy, hero images, and structured content via GROQ queries at request time or during Next.js ISR (Incremental Static Regeneration).
Core application — fetches in-app messaging, tooltips, onboarding flows, and help-center articles via the same Sanity Content API. The core app treats Sanity as any other REST/GraphQL backend — a simple fetch() call, no additional infrastructure needed.
This single source of truth prevents copy drift between the marketing site and the in-app experience.
2. Content Management: Sanity.io
Sanity acts as the decoupled database for all text, images, and structured content.
Schema-Driven Content
Content models are defined in code inside the Sanity Studio project (TypeScript schemas). The marketing Next.js app always receives predictable, strictly-typed JSON payloads. Example schema fragment:
Marketing users interact with Sanity Studio — a configurable web UI — to edit copy or select A/B test variant text. Sanity's Presentation tool enables live, in-context visual editing overlaid directly on the Next.js marketing site.
Cache Invalidation via Webhooks
When a content editor clicks Publish in Sanity Studio, Sanity fires a webhook to a /api/revalidate route hosted by the ui/marketing Cloud Run service. The route calls revalidatePath() or revalidateTag(), invalidating the relevant ISR cache entries. The live site reflects the new copy within seconds — no code deployment required.
Editor publishes in Sanity Studio
│
▼
Sanity fires POST webhook ──► ui/marketing /api/revalidate (Cloud Run)
│
▼
revalidatePath('/')
(or targeted revalidateTag)
│
▼
Fresh HTML served on next request
3. User Experimentation: PostHog
PostHog handles the statistical modeling and traffic-splitting for A/B tests (feature flags + experiments) with minimal frontend overhead — and doubles as the product-analytics and session-replay tool, so experiment exposures and outcomes land in the same system.
Scope: this section is the marketing site's server-side PostHog (posthog-node, running in ui/marketing middleware) — anonymous visitors, no PHI, flag-driven copy. The product apps wire PostHog differently: ui/web-app uses posthog-js and ui/ios uses posthog-react-native, both with session-replay masking on because they run on clinical screens — that setup lives in Frontend and Mobile. PostHog's full role across the stack (analytics + flags + experiments + replay + client error tracking + LLM observability & evals) is described in Observability.
Evaluating a flag should not add a network round-trip per request. Enable PostHog local evaluation: the posthog-node client polls flag definitions in the background and evaluates them in-process, so getFeatureFlag() resolves from memory. Instantiate a single long-livedPostHog client at module scope and reuse it across requests (constructing one per request defeats local evaluation and leaks connections).
On the marketing site, identify visitors by opaque IDs only (e.g., a random ph_visitor_id cookie) — never pass PHI (names, emails, MRNs) as a distinct ID or event property. PostHog is BAA-covered, but the discipline still holds: no PHI in flag keys, event names, or property keys, and enable PHI masking on session replay. See HIPAA Compliance.
Next.js middleware intercepts every request before it reaches the React rendering layer, running inside the ui/marketing Cloud Run service. The middleware:
Reads (or sets) a stable visitor ID cookie (ph_visitor_id).
Calls the PostHog SDK with the visitor ID to evaluate which experiment (feature-flag) variant the visitor qualifies for.
Stores the assigned variant in a request header (e.g., x-ph-variant: hero-b) so the page rendering functions can read it.
// lib/posthog.ts — a single long-lived server client (enables in-process local evaluation)import { PostHog } from 'posthog-node'export const posthog = new PostHog(process.env.POSTHOG_KEY!, { host: process.env.POSTHOG_HOST, // Poll flag definitions in the background so getFeatureFlag() resolves from memory (no per-request call). personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY,})
// middleware.tsimport { NextRequest, NextResponse } from 'next/server'import { posthog } from '@/lib/posthog'export async function middleware(req: NextRequest) { const visitorId = req.cookies.get('ph_visitor_id')?.value ?? crypto.randomUUID() // Evaluate the experiment flag for this anonymous visitor — opaque ID only, never PHI. const variant = (await posthog.getFeatureFlag('hero-experiment', visitorId)) ?? 'control' // Forward the variant as a *request* header so Next.js Server Components // can read it via `headers()` from 'next/headers'. const requestHeaders = new Headers(req.headers) requestHeaders.set('x-ph-variant', String(variant)) const res = NextResponse.next({ request: { headers: requestHeaders, }, }) res.cookies.set('ph_visitor_id', visitorId, { maxAge: 60 * 60 * 24 * 365 }) return res}export const config = { matcher: ['/'] }
Zero-Flicker Rendering
Once the middleware assigns a variant, the Next.js server fetches the corresponding variant copy from Sanity and serves a fully rendered HTML page. There is no client-side DOM manipulation, hydration mismatch, or UI flicker — the browser receives the correct content on the very first byte.
User request
│
▼
Next.js Middleware (ui/marketing on Cloud Run)
├─ Read/set visitor cookie
└─ Evaluate PostHog flag → variant = "hero-b"
│
▼
Next.js Page (Server Component)
└─ GROQ query: heroSection[variant == "hero-b"]
│
▼
Sanity Content API returns variant copy
│
▼
Fully-rendered HTML → browser (zero flicker)
Connecting Variant Data to Sanity
Sanity content schemas are extended to support variant fields or separate variant documents:
Marketing UI components are authored in React, Next.js, and Tailwind CSS. When AI-assisted scaffolding is used, it is Claude Code (see AI-Native Development) — output is committed straight into the ui/marketing workspace and reviewed like any other code.
Integration with Sanity & PostHog
Each component is wired up to live data in two steps:
Sanity data — Replace hard-coded strings with props fetched via GROQ queries in the parent Server Component (see the placeholder pattern below).
PostHog flags — The parent Server Component reads the x-ph-variant header (set by the Next.js middleware) and passes the appropriate variant props down to the component.
// app/page.tsx — Server Component wiring everything togetherimport { headers } from 'next/headers'import { sanityFetch } from '@/lib/sanity'import HeroComponent from '@/components/HeroComponent'export default async function HomePage() { const variant = headers().get('x-ph-variant') ?? 'control' const hero = await sanityFetch<HeroData>({ query: HERO_QUERY, params: { variant }, placeholder: { headline: 'The Fastest Way to Build Your Product', subheadline: 'Foundation gives your team a production-ready architecture from day one.', ctaLabel: 'Get Started Free', }, }) return <HeroComponent title={hero.headline} subtitle={hero.subheadline} ctaLabel={hero.ctaLabel} />}
5. Developer Velocity: Local Development & Placeholder Pattern
To ensure high developer velocity, engineers must be able to view and interact with the rendered application locally without requiring an active Sanity connection or a live PostHog API key.
Crucial Rule: Default Placeholder Props
Every React/Next.js component that is designed to consume Sanity API data must include robust default placeholder values for all of its props.
This allows developers (and AI agents generating components) to instantly preview rendered UI in a local environment or Storybook simply by dropping the component into a route — completely bypassing the need to fetch live backend data during initial UI iteration.
Example:
// components/HeroComponent.tsxinterface HeroComponentProps { title?: string subtitle?: string ctaLabel?: string ctaUrl?: string}const HeroComponent = ({ title = 'The Fastest Way to Build Your Product', subtitle = 'Foundation gives your team a production-ready architecture from day one — HIPAA-compliant, fully observable, and infinitely scalable.', ctaLabel = 'Get Started Free', ctaUrl = '/signup',}: HeroComponentProps) => { return ( <section className="flex flex-col items-center gap-6 py-24 text-center"> <h1 className="text-5xl font-bold tracking-tight">{title}</h1> <p className="max-w-2xl text-xl text-muted-foreground">{subtitle}</p> <a href={ctaUrl} className="rounded-lg bg-primary px-8 py-3 text-primary-foreground"> {ctaLabel} </a> </section> )}export default HeroComponent
Dropping <HeroComponent /> with no props into any route immediately renders a realistic, visually complete page with no external dependencies.
Local Sanity Fallback Pattern
For more complex components that issue GROQ queries, the data-fetching helper should return placeholder data when the Sanity project ID is not configured:
// lib/sanity.tsexport async function sanityFetch<T>({ query, params, placeholder }: { query: string params?: Record<string, unknown> placeholder: T}): Promise<T> { if (!process.env.NEXT_PUBLIC_SANITY_PROJECT_ID) { return placeholder } // ... live fetch}
The calling page passes placeholder data alongside the query, so the component tree renders correctly in any environment.
Personal API key that enables in-process local flag evaluation
All variables are optional in local development — the placeholder pattern handles their absence gracefully.
End-to-End Request Flow
Browser Request: GET /
│
▼
GCP Cloud Run (ui/marketing)
└─ Next.js Middleware
├─ Reads ph_visitor_id cookie (or generates one)
├─ Calls PostHog → assigns variant "hero-b"
└─ Sets x-ph-variant: hero-b header
│
▼
Next.js Server Component (app/page.tsx)
├─ Reads x-ph-variant header
├─ GROQ fetch → Sanity Content API
│ └─ Returns hero copy for variant "hero-b"
└─ Renders HeroComponent with live props
│
▼
Fully-rendered HTML sent to browser
(no client-side flicker, no layout shift)
│
▼
PostHog captures the experiment-exposure event
($feature_flag_called) for statistical analysis
Marketing runs as its own Cloud Run service (ui/marketing), separate from the applications frontend (ui/web-app) and the backend API — but it is still part of the same GCP Cloud Run platform, not a third-party host. Marketing content updates (copy edits, A/B variant launches) require zero involvement from backend or platform engineers.
All application secrets — third-party API keys, webhook signatures, database connection strings, auth signing keys — live in GCP Secret Manager (the runtime store, inside the Google BAA), with Pulumi ESC (Environments, Secrets, and Configuration) as the declarative source of truth. There is no database-resident vault.
This replaces the older Supabase Vault design. With Supabase removed, a DB-resident vault is both unavailable (Neon does not support supabase_vault / pgsodium) and undesirable — DB-resident secrets would be copied into every preview branch by branch-per-PR, exactly the wrong direction.
Core Philosophy: Declarative Source of Truth → IAM-Scoped Runtime Store
Pulumi ESC holds the declarative definition of every environment's config + secrets, alongside the Pulumi IaC we already own. It can also mint short-lived, dynamic credentials rather than storing static ones.
GCP Secret Manager is the runtime store: covered by the Google BAA, IAM-scoped, and natively integrated with Cloud Run (secrets mounted as env vars or files at deploy).
Pulumi ESC (declarative — source of truth)
→ materialize → GCP Secret Manager (runtime — Google BAA, IAM-scoped)
→ mounted into Cloud Run at deploy time
How materialization works
"Materialize" is a concrete Pulumi step, not hand-copying. The Pulumi program in infra/imports the ESC environment and, for each declared secret, creates a gcp.secretmanager.Secret + SecretVersion resource whose value comes from ESC:
So ESC is the declarative source of truth, the Pulumi run materializes it into Secret Manager (the runtime store), and Cloud Run mounts the resulting Secret Manager versions at deploy. Rotating a value in ESC and re-running Pulumi adds a new SecretVersion; the old version is destroyed after cutover.
Mount secrets into Cloud Run at deploy, not fetched per request. Destroy old Secret Manager versions after a rotation cuts over.
What lives here
Neon per-branch DATABASE_URLs
Better Auth secrets / JWT signing keys
Vendor API keys (Telnyx, Sanity, etc.)
OAuth client secrets
Per-PR preview secrets (minted in CI, destroyed with the Neon branch)
Local Development
Local secrets go in a git-ignored .env file at the repo root.
Hydrate it from Pulumi ESC so local matches the declared shape without hand-copying:
pulumi env open <project>/dev --format dotenv > .env # or: esc run <env> -- <cmd>
The .env file must be listed in .gitignore. Never commit local secrets.
Production & Preview
Production secrets are declared in Pulumi ESC and materialized into GCP Secret Manager; Cloud Run mounts them at deploy. Rotate by adding a new Secret Manager version, then destroying the old one after cutover.
Preview (per-PR) secrets — including the ephemeral Neon branch DATABASE_URL and the branch-scoped Better Auth config — are minted in CI and torn down with the Neon branch on PR close (see PR Environments).
Foundation ships a set of reusable GitHub Copilot Skills — structured prompt files that teach Copilot (and other AI agents, including Claude Code — the primary coding agent) how to perform common, multi-step development tasks consistently across every project that follows this architecture.
Skills live in .github/skills/<skill-name>/SKILL.md. They are picked up automatically by GitHub Copilot when the agent reads the repository.
Why Skills?
Without a skill
With a skill
Agent guesses the right lint/test commands
Agent runs the exact commands from lefthook.yml
Inconsistent fix strategies across sessions
Deterministic, documented procedure every time
Silent failures when a step is skipped
Required steps are explicit; status table always reported
Write the procedure using numbered steps with exact shell commands.
End with a reporting section (status table or structured output).
Copy the file to public/templates/skills/<skill-name>/SKILL.md in this repo and add a row to the Available Skills table above.
Skills are plain Markdown. They do not require special tooling — any AI agent that reads the repository can follow them.
The Stack
Complete service catalog with pricing — every SaaS, cloud platform, and operational tool the Foundation Architecture depends on.
The Stack
A complete inventory of every paid service behind the Foundation Architecture — what each one does, what it costs, and how the bill scales from a 3-person startup to a 100-person organization.
Prices are approximate and reflect publicly listed rates as of early 2026. Always confirm with each vendor before budgeting. Annual billing discounts (typically 10–20%) are available for most services but are not factored into the estimates below unless noted. Usage-based services (Neon, GCP, Cloudflare, etc.) are estimated for a typical SaaS product with moderate traffic.
Stack pricing is an API — call it (the interactive calculator on the human site is just a front-end for this).
OpenAPI spec:GET /api/pricing/openapi.json (relative to this origin) — full request/response schema + examples.
The docs UI and the API are one Cloud Run service (fronted by Cloudflare); the API lives under /api (e.g. https://foundation.h11h.io/api). The OpenAPI's relative servers (/api) resolves against whatever origin serves the spec.
Service catalog — by vendor
Every service the architecture depends on, grouped by the vendor that provides it, with the logical function each one fills (sourced from the Recombinate IT Functions Map). Pricing detail and per-team totals follow below.
Global load balancing + WAF / DDoS for the app & API PHI path — the governed edge in front of Cloud Run (Cloudflare covers only the marketing edge / DNS)
Usage-based
usage *
↳ GCP-native observability
Observability + backend error tracking
Cloud Logging, Cloud Trace, Cloud Monitoring, Error Reporting (Cloud Run backend error tracking) — OpenTelemetry → GCP backends, inside the Google BAA
Usage — 50 GiB / mo logs free, then ~$0.50 / GiB
~$0 *
↳ Vertex AI (Gemini)
AI — reasoning / embeddings / voice
Managed Gemini models for product reasoning, embeddings (pgvector retrieval) and voice
Usage-based (per token / request)
usage *
Google Workspace (Essentials Starter)
Office suite (field / ops secondary)
Drive, Docs, Meet collaboration; no corporate Gmail seat needed
Free
$0
Neon
Neon (Scale)
OLTP database + vector search
Serverless PostgreSQL — branch-per-PR, autoscaling, pgvector. HIPAA on Scale: set hipaa:true + self-serve BAA, no separate add-on
DNS, DDoS protection, CDN, Pages for static assets — marketing edge only; the app/API PHI edge + WAF is GCP Cloud Armor (above)
$20 / mo flat (not per-user)
~$7 *
Tailscale
Tailscale
VPN / secure access
Tailnet — SSO-gated WireGuard mesh for access to infra, preview environments, and internal services
$6 / user / mo (builder + office)
$6 / user
Pulumi
Pulumi Cloud
Infrastructure as code + secrets (ESC)
IaC state management, CI/CD drift detection, Pulumi ESC secrets
Individual free (1 user); Team $40 / mo flat (≤ 10 users); Enterprise $400 / mo (unlimited)
~$0–$4 *
Restate
Restate Cloud (managed)
Durable execution
Durable-execution runtime for background jobs and the agentic, human-in-the-loop agent loop — managed serverless runtime (us) driving Cloud Run scale-to-zero handlers
Managed service — free tier: 50k actions/mo, then usage-based
~$0 *
Cerbos
Cerbos (self-hosted OSS)
Authorization (app-level)
Stateless authorization policy-decision-point — policy-as-code (YAML + CEL); Query Plan API emits SQL WHERE filters that pair with Neon RLS
Usage-based — voice ~$0.002 / min, SMS ~$0.004 / msg, DIDs ~$1 / number / mo
~$50 *
Clay
Clay.com (Growth)
Sales data / enrichment
Data enrichment & prospecting automation — unlimited seats, credit-based
$446 / mo flat (unlimited users)
~$149 *
ReachInbox
ReachInbox (Growth)
Cold outreach / deliverability
AI-powered cold email sequencing with unlimited sending accounts
$99 / mo flat
~$33 *
WellSky
WellSky Personal Care
Home-care platform / EHR (+ referral mgmt · e-fax · Family Room)
Clinical system of record — scheduling, EVV, billing, referral / e-fax intake, family portal (API integration)
Contract-based (per-agency / per-seat)
Quote
AWS
AWS SES
Transactional email (product)
Product transactional email (us-west-2, co-located with Neon) — free self-serve BAA, no PHI in bodies
Usage — ~$0.10 / 1,000 emails
~$0–1 *
1Password
1Password (Business)
Password / secrets manager (people)
Team password vaults, secrets sharing, SSO integration
$7.99 / user / mo
~$8 / user
Sage
Sage Intacct
Finance / accounting
Core accounting / GL, AP / AR, financial reporting
Quote-based (annual contract)
Quote
Ramp
Ramp
Corporate cards / expense
Corporate cards, expense management, bill pay
Free (interchange-funded)
$0
Dropbox
Dropbox Sign (Standard)
E-signature
E-signature with BAA (annual)
$15–25 / user / mo (Standard, annual)
~$15 / user
Peony
Peony Data Room
Virtual data room (M&A / diligence)
Diligence / fundraising data rooms — unlimited rooms, watermarking, advanced NDA, AI doc chat; SharePoint convention retained for small tuck-ins
$52 / admin / mo (self-serve, flat per-admin; unlimited free viewers, no per-GB fees)
~$52 / admin
* Shared / usage-based services — the per-user figure is the total monthly bill divided by team size; actual unit cost drops as the team grows. Usage-based estimates (Telnyx ≈ 5 numbers + moderate volume; PostHog ~$50/mo light → ~$100/mo heavier; Clay / ReachInbox flat-rate shown as a 3-person share) scale with traffic, not headcount.
Microsoft 365 Business Premium is the primary productivity and security plane for office / clinical / engineering / sales staff — one $22 bundle covers Office, email, and Teams plus the whole security stack (Entra P1 SSO/MFA/Conditional Access, Intune MDM, Defender for Business EDR, Purview DLP/labels), so those show as $0 bundled sub-rows above rather than separate line items. Escalate to Entra P2 (ID Protection, PIM) as you grow; the governed-PHI-plane controls are detailed in Business Operations + HIPAA Compliance. Google Workspace is carried only for field / operations staff, on the free Essentials Starter tier (Drive/Docs/Meet, no paid seat). (Field caregivers authenticate to the product via Better Auth — they don't consume a workforce M365 seat.)
Open-source stack — $0 license
The product itself runs on an open-source build/runtime stack — no license cost. Runtime for the self-hosted pieces (Better Auth, FastAPI, plus Cerbos / Coder above) folds into the GCP compute already counted.
Steward
Product
Function
Cost
TanStack (OSS)
Vite + TanStack (Router + Query)
Web application UI
$0
Vercel (OSS)
Next.js
Marketing site
$0
Expo (OSS)
Expo + React Native
Mobile app
$0
Better Auth (OSS)
Better Auth (self-hosted)
Product auth
$0
Open source
FastAPI (Python)
Backend API
$0
Open source
OpenAPI → openapi-typescript
API contract
$0
Drizzle (OSS)
Drizzle Kit
Schema & migrations
$0
dbt Labs
dbt (on BigQuery)
Data transform (ELT)
$0 (OSS core)
Vercel (OSS)
Turborepo
Monorepo / build
$0
Astral / Biome
Biome (TS) · Ruff (Py)
Lint / format
$0
Open source
Lefthook
Git hooks
$0
Jetify (OSS)
Devbox + direnv + process-compose
Local dev environment
$0
Undecided / WIP
Functions still in bake-off or active build — tracked in the IT Functions Map; no line-item cost committed yet.
Function
Candidates
Owner
Status
Enterprise productivity AI
M365 Copilot vs Gemini for Workspace
All staff
⚠️ Bake-off
Marketing / lifecycle email
Customer.io vs Iterable vs Loops
Marketing / Growth
⚠️ TBD (BAA gating)
Caregiver scheduling AI (on WellSky)
Zingage vs Phoebe vs Alden
Ops / Eng
⚠️ TBD
Product voice / telephony
Telnyx + Pipecat + Gemini Live (currently favored setup)
Engineering
⚠️ WIP
HR & Payroll — pick one
Rippling and Deel serve the same function — you choose one based on your team's location and employment model. Rippling is strongest for US-centric teams; Deel excels at international contractors and Employer-of-Record (EOR) arrangements.
Vendor
Service
Function
What it does
Base platform fee
Per-employee / mo
Per-contractor / mo
Rippling
Rippling (Core HR + Payroll)
HR / payroll / benefits
US payroll, benefits admin (device management migrates to Intune)
$35–$40 / mo
$20
—
Deel
Deel (Global)
Global payroll / EOR / contractors
International payroll, contractor management, EOR
Free HRIS (≤ 200 employees)
$29 (payroll) / $599 (EOR)
$49
Remote Development Environments
The architecture assumes Coder (self-hosted, Community edition) as the primary development environment. GitHub Codespaces is a viable managed alternative — pricing for both is shown below.
Coder (Community — self-hosted)
Coder is free and open-source; you pay only for the underlying infrastructure. The estimates below assume GCP, no HA, and no paid Coder support.
Beefy GPU for direct AI / ML development — not every developer needs this
RunPod A100 80 GB VRAM, ~40 hrs / mo on-demand
~$55 / dev
GitHub Codespaces (managed alternative)
Service
What it does
Pricing model
Est. cost / user / mo
GitHub Codespaces
Managed cloud dev environments integrated with GitHub
Usage — ~$0.36 / hr (4-core) + $0.07 / GB storage
~$18
Total Cost Estimates by Team Size
Assumptions
The estimates below apply these conventions:
Neon (Scale) is the database. Pricing is usage-based with no monthly minimum (~$0.26 / CU-hr compute + storage). HIPAA is included on Scale — set hipaa:true and accept the self-serve BAA at no separate add-on cost. Because HIPAA no longer changes the bill, the totals below are a single figure — there is no separate "+ HIPAA" column, as there was under the old Supabase-based estimates.
All web runs on GCP Cloud Run — the applications UI (Vite + TanStack) and the marketing site (Next.js). Vercel is not used.
Observability is GCP-native (Cloud Logging / Trace / Monitoring via OpenTelemetry) — effectively free at launch volumes and folded into GCP usage. No Elastic / ELK.
Durable execution (background jobs + the agentic human-in-the-loop agent loop) runs on Restate Cloud (managed, serverless) driving Cloud Run scale-to-zero handlers — no always-on node to operate; its free tier (50k actions/mo) covers launch volumes, moving to usage-based pricing as traffic grows.
Microsoft 365 Business Premium is the primary productivity + security suite for office / clinical / eng / sales staff; Google Workspace Essentials (free) covers field / ops collaboration.
Microsoft 365 is added for a subset of employees who need Office desktop apps (Excel, etc.).
Rippling is the HR / payroll provider (US-centric team).
Coder (Community, self-hosted on GCP) is the dev environment — not Codespaces.
GPU add-on is excluded from the base estimates (add ~$55 / mo per AI-focused developer).
Pulumi Cloud is tiered by the number of Pulumi users, not total employee count: Individual (free, 1 user), Team ($40 / mo flat, up to 10 users), Enterprise ($400 / mo, unlimited). In the estimates below, even larger companies can remain on Team if only a small platform / infra group needs Pulumi access.
Shared / usage-based services (Neon, GCP, Cloudflare, Telnyx, Clay, ReachInbox, PostHog) appear once in the total — they are not duplicated per user. PostHog is billed on event / session-replay volume, not seats.
Usage-based cloud services scale sub-linearly with headcount.
Team of 3
At three people everyone wears every hat — founder, engineer, salesperson, support. All three need access to every service. Pulumi starts free on the Individual tier — one person can manage deployments for the whole team.
Coder control plane + 30 dev machines (4-core spot)
$775
Grand total
~$11,696 / mo
Per person
~$117 / mo
Per-person cost drops dramatically at scale because usage-based infrastructure (Neon, GCP, Cloudflare, Telnyx, Clay, ReachInbox, PostHog) is shared, and many employees (Field / Operations) only need basic communication and HR tooling. The largest variable-cost drivers remain per-seat services: GitHub Copilot ($19), Claude ($25), and Linear ($16).
Programmatic pricing — role → cost
The tables above are reference snapshots. The live model is a typed function that prices any team from a request object:
import { calculateStackCost } from '@/lib/pricing/stack-calculator';calculateStackCost({ composition: { cto: 1, 'software-engineer': 6, scheduler: 2, caregiver: 25 }, mau: 15000, // REQUIRED — drives usage-based services (Neon, GCP, PostHog, Telnyx) hipaa: true, // default true — toggles per-service HIPAA add-ons overrides: { // optional, per service id github: { billTier: 'office' }, // switch which tier a per-seat tool bills against linear: { seats: 5 }, // hard-code the scale factor clay: { disabled: true }, // turn a service off gcp: { usageMultiplier: 2 }, // scale a usage service },});// → { headcount, lines[], perCategory[], grandTotalMonthly, perPersonMonthly, hipaaPremiumMonthly, notModeled, warnings }
Roles + tiers in src/lib/pricing/team.ts (Builder / Office / Field); the service cost model in stack-model.ts (per-seat / flat / MAU-usage, each with optional HIPAA add-ons). The "everyone builds" rule: all Office + Builder get Linear + a Claude coding-agent seat + Tailscale; only Builders add GitHub + Copilot + Coder.
MAU is required — usage services (Neon, GCP, PostHog, Telnyx) scale with monthly active users, not headcount.
HIPAA (default true) adds per-service premiums (M365 Business Premium, Neon Scale, PostHog Boost); the total is returned as hipaaPremiumMonthly so the HIPAA delta is visible directly.
Overrides per service id: billTier (switch group), seats (hard-code scale), usageMultiplier (scale usage), disabled (off).
CLI:pnpm pricing '{"composition":{"software-engineer":2},"mau":5000}'; pnpm pricing:gen regenerates the static JSON.
OpenAPI spec (for agents): served at /api/pricing/openapi.json. The docs UI and this API run in one Cloud Run service (fronted by Cloudflare); the API is mounted under /api, so POST /api/pricing, POST /api/pricing/export.xlsx, GET /api/pricing/roles.json / scenarios.json, and MCP-over-HTTP at POST /api/mcp are all live. The spec's servers URL is relative (/api), so it resolves against whatever origin serves it — http://localhost:8080/api locally (pnpm api), https://foundation.h11h.io/api in prod.
Role-tied services: WellSky (caregivers + clinical), Sage/Ramp (finance + C-suite), Clay/ReachInbox (billed only if a sales role is present), Figma (design/PM), Sanity (marketing), Peony data room (admin seats: CEO + CPO/CTO + the Corporate Development / Strategy role, which defaults to 0). Still not modeled (in notModeled): Dropbox Sign, AWS SES.
The reference tables above still reflect the earlier headcount model; the function encodes the newer everyone-builds tiering + MAU/HIPAA, so its totals differ. The tables can be regenerated from the function when we want it to be the single source of truth.
As You Grow
The services above cover a team from founding through Series A. As the organization scales beyond ~50 people or traffic grows significantly, consider adding:
Service
What it does
When to adopt
Pricing model
Jetify Cloud (Starter → Scaleup)
Private Nix binary cache for Devbox — eliminates redundant builds and speeds up onboarding from minutes to seconds
> 10 developers sharing a Devbox config
$25 / mo (10 users) → $150 / mo (50 users); $0.60 / GB cache storage
Neon (Business)
Higher compute + storage ceilings, longer point-in-time recovery, more branches — same HIPAA posture as Scale
High-availability or heavier OLTP / branch-per-PR volume
Usage-based with higher included limits (contact sales for committed-use discounts)
When OpenTelemetry + Cloud Monitoring alone can't keep up with multi-region, multi-service sprawl
Usage-based — typically $15–$35 / host / mo
Pulumi Cloud (Enterprise)
Unlimited resources, SAML/SSO, audit logs, dedicated support
> 10 Pulumi users
$400 / mo flat (unlimited users)
Coder (Premium)
Multi-org RBAC, audit logging, workspace proxies, SLA-backed support
> 25 developers or compliance-driven workspace isolation
Custom pricing — annual per-user
Lightdash or Wren AI
Governed BI / conversational analytics on top of your data warehouse
When stakeholders need self-serve reporting beyond raw SQL
Lightdash: OSS or Cloud from $0; Wren AI: OSS
Business Operations
Business Operations — Recombinate
This is the non-product IT / operations stack for the company operating the home-care business under the Recombinate umbrella. It covers the tools staff use to run the business — productivity, identity, HR, finance, sales, and support — and complements the product/engineering stack documented elsewhere in Foundation. Product infrastructure (Neon, GCP, WellSky, the agent-driven onboarding funnel) is where PHI is architected to live; this layer is where the company runs.
The workforce plane (Microsoft 365 — Teams, SharePoint, OneDrive, Exchange) will contain PHI in practice (Excel/Word files with patient info), so it is treated as a governed plane under a Microsoft BAA, with Purview labeling/DLP + Intune device control + Entra Conditional Access — the control is policy about where PHI lives and who accesses it, not pretending PHI stays out. By contrast, Notion is NOT HIPAA-covered (no BAA) — no PHI of any kind in Notion. PHI at rest lives only in Neon / GCS / Vertex / WellSky (+ the governed M365 plane).
Productivity, Communication & Identity
Function
Role
Vendor
Product
Office suite / email / calendar
Office / clinical staff (primary); field / ops (secondary)
Microsoft (primary)
Microsoft 365 Business Premium (primary — office/clinical staff); Google Workspace (field / ops)
Enterprise productivity AI
All staff
Microsoft / Google
⚠️ Bake-off — M365 Copilot vs Gemini for Workspace (undecided)
Team chat
All staff
Microsoft
Microsoft Teams (bundled with M365)
Project management (business)
All staff / Ops
Notion
Notion — business task + milestone tracking for business objectives (distinct from Linear, which drives product/eng)
Workforce identity / SSO / MFA
IT / All
Microsoft
Microsoft Entra ID (P1 → P2 as you grow)
Device management (MDM)
IT
Rippling → Microsoft
Rippling device management initially (inherited, Pro plan) → Microsoft Intune later
Endpoint security / EDR
IT / Security
Microsoft
Microsoft Defender for Business
Data governance / DLP / labels
IT / Compliance
Microsoft
Microsoft Purview
Password / secrets manager (people)
All staff
1Password
1Password
Microsoft 365 Business Premium is the primary suite for office / clinical staff — it bundles Office + Entra ID P1 + Intune + Defender for Business + Purview, giving one identity / security / governance plane. Google Workspace is the secondary suite for field / ops staff.Entra ID P1 ≈ $6/user, no minimum.Defender for Business (bundled in Business Premium) was chosen on price; CrowdStrike Falcon is slated for post-$5M EBITDA.
People, Finance & Legal
Function
Role
Vendor
Product
HR / payroll / benefits
HR / Ops
Rippling
Rippling Core HR + Payroll
Finance / accounting
Finance
Sage
Sage Intacct
Corporate cards / expense
Finance
Ramp
Ramp
E-signature
Legal / Ops
Dropbox
Dropbox Sign (Standard, annual + BAA)
Compliance / GRC (HIPAA program)
Compliance / Security
⏸ Deferred
Vanta / Drata — deferred to Q1 2027 (pre-go-live)
Legal / contracts / entity mgmt
Legal
Outside counsel
No dedicated tooling yet
Business insurance
Ops / Finance
Specialist broker
Home-care broker — professional liability + general liability + workers' comp + non-owned auto + abuse/molestation; cyber + D&O via the broker or a digital broker (Vouch/Embroker)
Sage Intacct was chosen for multi-entity accounting — the plan assumes aggressive M&A and keeps acquired entities separate. The Vanta / Drata GRC tool is deferred, but the underlying HIPAA controls still apply now regardless of tooling. Insurance goes through a home-care-experienced broker, not a tech-only digital broker: professional liability, workers' comp, and non-owned auto are home-care-specific needs a generalist broker will miss.
Corporate Development / M&A
The plan assumes an active acquisition motion (~a dozen deals over ~5 years), so corp-dev tooling is first-class rather than ad hoc.
Function
Role
Vendor
Product
Virtual data room (diligence / fundraising)
M&A / Legal
Peony
Peony Data Rooms (flat per-admin subscription); a SharePoint-folder convention is retained for small tuck-ins
Deal pipeline / target tracking
M&A / Exec
Notion
Notion target database; revisit 4Degrees or Affinity at >25 active targets or the first corp-dev hire
Peony was chosen for flat per-admin pricing — data-room vendors that bill per page/user penalize an aggressive deal cadence. The deal pipeline lives in Notion for now (cheap, flexible); a dedicated relationship-intelligence CRM (4Degrees / Affinity) is deferred until target volume or a corp-dev hire justifies it. No PHI in either — deal and target data are corporate, not patient, data (and Notion carries no BAA regardless).
Sales, Marketing & Support
Function
Role
Vendor
Product
Sales data / enrichment
Sales / Partnerships
Clay
Clay (Growth) — referral-source development
Cold outreach / deliverability
Sales / Partnerships
ReachInbox
ReachInbox (Growth) — channel/referral outreach
CRM / prospect funnel
Product / GTM
Built-in + WellSky
Product-native funnel (HomeCareHQ) + WellSky referral mgmt + BigQuery analytics — no traditional CRM
WellSky Family Room + product agent + shared inbox — dedicated helpdesk (Front) deferred until volume warrants
Transactional email (product)
Engineering
AWS
AWS SES (free self-serve BAA; us-west-2, co-located with Neon) — no PHI in email bodies
Patient comms — SMS / voice
Ops / Clinical
Telnyx
Telnyx (shared with the product voice stack)
Fax (referrals / clinical)
Clinical / Ops
WellSky
WellSky referral / e-fax intake (AI-summarized); SRFax fallback if it's a heavy add-on
There is no traditional CRM. The CRM is built into the product — the acquired HomeCareHQ funnel — because prospects describe care needs (PHI) during onboarding. That puts the funnel inside the HIPAA boundary, and the agent-driven onboarding is the core differentiator, so a bolt-on CRM would be both redundant and a PHI leak. SES was chosen for lowest cost plus a free self-serve AWS BAA, co-located with Neon (us-west-2). Fax is largely covered by WellSky's referral / e-fax intake.
See also:
HIPAA Compliance — the trust zone, vendor BAAs, and services that must never receive PHI