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:

{
  "mcpServers": {
    "foundation": { "url": "https://foundation.h11h.io/api/mcp" }
  }
}

MCP tools: get_architecture_doc (read any doc, WIP-stripped) · price_stack (role → cost API) · get_pricing + list_services (per-service catalog).

Home

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:

StepWhat happens
1. Write a ticketDescribe 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 agentA greenlit coding agent (Claude Code / GitHub Copilot) picks up the ticket and opens a pull request implementing the change.
3. Spin up a preview environmentEvery 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 escalateCODEOWNERS 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 productionOnce 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

LayerTechnologyHosting
Frontend (Applications)Vite + TanStack (Router + Query), TypeScriptGCP Cloud Run
Frontend (Marketing)Next.js 14, App Router, TypeScriptGCP Cloud Run
AuthBetter Auth — hosted by the applications-UI serverGCP Cloud Run
AuthorizationCerbos (policy-as-code PDP, self-hosted) — with Neon RLS as the enforcement floorGCP Cloud Run
Frontend (Mobile)Expo, React Native, TypeScriptApp Store / Play Store
Backend APIFastAPI (HTTP only), Python 3.11+ — backend/apiGCP Cloud Run
API ContractFastAPI OpenAPI → typed TS client (openapi-typescript)
Durable ExecutionRestate Cloud (managed) — virtual objects + durable promises, suspend-to-zero — backend/workerRestate Cloud + GCP Cloud Run
AI TierGemini on Vertex AI — reasoning · embeddings · voiceGCP
OLTP DatabaseNeon (PostgreSQL)Neon (Scale plan, BAA)
OLAP / Data WarehouseGoogle BigQueryGCP
Data Transformdbt (incremental, Neon → BigQuery)GitHub Actions (scheduled)
Backups & DRNeon PITR · BigQuery time-travel · GCS versioning
Infrastructure as CodePulumi (TypeScript)Pulumi Cloud
ObservabilityOpenTelemetry → GCP-native (Logging / Trace / Monitoring)GCP
SecretsPulumi ESC + GCP Secret ManagerGCP
CI/CDGitHub ActionsGitHub
MonorepoTurborepo
JS/TS ToolingBiome (lint + format), Lefthook (hooks)
Python ToolingRuff (lint + format), pytest
Dev EnvironmentDevbox, direnv, process-compose, Coder

Key Design Principles

  1. 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.
  2. 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.
  3. Monorepo with strict modularity. One concern per file. Enables parallel AI-assisted development with zero merge conflicts.
  4. Cross-cloud data bridge. Neon (OLTP, source of truth) → BigQuery (OLAP) via incremental sync (WHERE updated_at >), minimizing egress across the GCP ↔ AWS boundary.
  5. Reproducible environments. Devbox + direnv + process-compose ensure every developer gets an identical setup in one command.
  6. Coverage gates. ≥ 80% test coverage enforced in CI. No PR merges without passing all quality checks.

Documentation Sections

SectionDescription
Incubation WorkflowRapid prototyping with GitHub Spark and a zero-friction graduation path into the monorepo
Monorepo StructureTurborepo layout, workspaces, and build pipelines
Frontend (Web)Vite + TanStack applications, Next.js marketing site, Better Auth, Cloud Run hosting
MobileExpo + React Native with expo-router
BackendFastAPI HTTP (backend/api) + Restate durable worker (backend/worker), Neon, Docker
AI TierGemini on Vertex — reasoning/orchestration, embeddings + pgvector, voice; in-boundary
InfrastructurePulumi IaC for GCP + Neon setup notes
Data Pipelinedbt models, Neon → BigQuery, incremental sync
Backups & RecoveryNeon PITR, BigQuery time-travel, GCS versioning, RTO/RPO + restore runbook
CI/CDGitHub Actions quality gates and deployments
PR Preview EnvironmentsEphemeral full-stack environments per pull request
Secure PHI GitOpsDual-Neon PHI/non-PHI split, isolated Coder compute, and the dev/main/release branch strategy
Developer ToolingDevbox, Biome, Ruff, Lefthook, dev containers
Code OrganizationFile modularity and the "one concern per file" rule
ObservabilityOpenTelemetry → GCP-native + PHI scrubbing
HIPAA ComplianceVendor BAAs, the PHI-free service list, technical controls
LibrariesCanonical Python and JS/TS library reference
Environment VariablesMaster env var reference across all services
Content ManagementDecoupled marketing stack — Sanity.io CMS + PostHog (product analytics + feature flags + experiments + session replay + error tracking + LLM observability & evals)
Secret ManagementPulumi ESC + GCP Secret Manager
GitHub Copilot SkillsReusable agent skills for common multi-step dev tasks
The StackComplete service catalog with pricing at every team size
Business OperationsNon-product IT/ops stack — M365 identity/security, finance, GTM, support

Prompt Templates

This architecture ships with two prompt templates for AI-assisted project management:

TemplatePurpose
GITHUB-JUMPSTART.mdScaffold a new project from scratch following this architecture
APPLY-ARCHITECTURE.mdMigrate 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.


Product — Frontend & Client

FunctionRoleVendorProduct
Web application UIEngineeringOpen source / GoogleVite + TanStack (Router + Query) → Cloud Run
Marketing siteEng / MarketingOpen source / GoogleNext.js 14 → Cloud Run
Mobile appEngineeringExpo (OSS)Expo + React Native
Product authEngineeringBetter Auth (OSS)Better Auth (self-hosted on app server)
Feature flags / experimentsEng / ProductPostHogPostHog Cloud (Boost+, BAA) — flags + experiments
Marketing CMSMarketing / EngSanitySanity (Growth)

Product — Backend, Data & AI

FunctionRoleVendorProduct
Backend APIEngineeringOpen source / GoogleFastAPI (Python) → Cloud Run
API contractEngineeringOpen sourceOpenAPI → openapi-typescript client
OLTP databaseEngineeringNeonNeon Scale (Postgres, HIPAA / BAA)
Authorization (app-level)EngineeringCerbos (OSS)Cerbos PDP (policy-as-code YAML/CEL; Query Plan → SQL filters) + Neon RLS floor
Schema & migrationsEngineeringDrizzle (OSS)Drizzle Kit (schema/)
Vector search / retrievalEngineeringNeonpgvector (in Neon)
OLAP warehouseEng / DataGoogleBigQuery
Data transform (ELT)Data / Engdbt Labsdbt (BigQuery)
Durable executionEngineeringRestateRestate (Restate Cloud, managed) — durable agent runtime + human-in-the-loop
AI — reasoning / embeddings / voiceEngineeringGoogleVertex AI — Gemini
LLM observability / evalsEngineeringPostHogPostHog Cloud (Boost+, BAA) — LLM observability + evals
Home-care platform / EHREng / ClinicalWellSkyWellSky Personal Care (API integration)
Caregiver scheduling AI (on WellSky)Ops / Eng⚠️ TBD⚠️ Bake-off: Zingage vs Phoebe vs Alden
Product voice / telephonyEngineeringTelnyx (+ Google)⚠️ WIP — Pipecat + Telnyx / Gemini Live

Product — Infrastructure, DevOps & Observability

FunctionRoleVendorProduct
Cloud platformEngineeringGoogleGCP (Cloud Run, GCS, Artifact Registry, WIF)
Infrastructure as codeEngineeringPulumiPulumi Cloud
CI / CDEngineeringGitHub (Microsoft)GitHub Actions
Source controlEngineeringGitHub (Microsoft)GitHub Team + Copilot
Edge / CDN / DNS / WAFEngineeringCloudflare + GoogleCloudflare Pro (marketing + DNS) · GCP Cloud Load Balancing + Cloud Armor (app/API PHI edge)
Secrets managementEngineeringPulumi + GooglePulumi ESC + GCP Secret Manager
ObservabilityEngineeringGoogleOpenTelemetry → Cloud Logging / Trace / Monitoring
Error tracking (client + app)EngineeringGoogle + PostHogGCP Error Reporting + PostHog error tracking; Sentry as escalation path
Alerting / on-callEngineeringGoogleCloud Monitoring alert policies + SLOs → Teams / email (PagerDuty later)
Backups / DREngineeringNeon + GoogleNeon PITR, BigQuery time-travel, GCS versioning
Object storageEngineeringGoogleGCS (CMEK + signed URLs) — uploads / documents
Caching / rate-limit storeEngineeringNeon (Redis deferred)Neon now; Memorystore (Redis) when pressure appears
Monorepo / buildEngineeringOpen sourceTurborepo
Lint / formatEngineeringOpen sourceBiome (TS) · Ruff (Py)
Git hooksEngineeringOpen sourceLefthook
Local dev environmentEngineeringOpen sourceDevbox + direnv + process-compose
Remote dev environmentsEngineeringCoderCoder (self-hosted on GCP)
VPN / tailnet (non-prod access)EngineeringTailscaleTailscale — internal LB + subnet router; gates previews / staging / Coder (prod stays public, no BAA needed)
Coding agentEngineeringAnthropic / GitHub⚠️ WIP — Claude Code (primary) + GitHub Copilot

Product build — Collaboration & Knowledge

FunctionRoleVendorProduct
Project / product management / issuesProduct / EngLinearLinear Business — product docs live in Notion
Docs / knowledge baseAll (product)NotionNotion Business / Enterprise
Meeting notes / transcriptionAllNotionNotion AI Meeting Notes — bundled with Notion Business (replaces Granola)
Design / prototypingProduct / EngFigmaFigma Professional — design system + brand source of truth

Business Ops — Productivity, Communication & Identity

FunctionRoleVendorProduct
Office suite / email / calendarAll staffMicrosoft (primary)Microsoft 365 (primary) · Google Workspace (secondary)
Enterprise productivity AIAll staffMicrosoft / Google⚠️ Bake-off — M365 Copilot vs Gemini for Workspace
Team chatAll staffMicrosoftMicrosoft Teams (bundled with M365)
Project management (business)All staff / OpsNotionNotion — business task + milestone tracking (distinct from Linear for product)
Workforce identity / SSO / MFAIT / AllMicrosoftMicrosoft Entra ID (P1 → P2)
Device management (MDM)ITRippling → MicrosoftRippling device mgmt initially (Pro) → Microsoft Intune later
Endpoint security / EDRIT / SecurityMicrosoftMicrosoft Defender for Business
Data governance / DLP / labelsIT / ComplianceMicrosoftMicrosoft Purview
Password / secrets manager (people)All staff1Password1Password

FunctionRoleVendorProduct
HR / payroll / benefitsHR / OpsRipplingRippling Core HR + Payroll
Finance / accountingFinanceSageSage Intacct
Corporate cards / expenseFinanceRampRamp
E-signatureLegal / OpsDropboxDropbox Sign (Standard, annual + BAA)
Compliance / GRC (HIPAA program)Compliance / Security⏸ Deferred⏸ Vanta / Drata — deferred to Q1 2027 (pre-go-live)
Legal / contracts / entity mgmtLegalOutside counselNo dedicated tooling yet
Business insuranceOps / FinanceSpecialist brokerHome-care broker — prof. liability + GL + workers' comp + non-owned auto + abuse; cyber/D&O via broker or Vouch/Embroker

Business Ops — Corp Dev / M&A

The acquisition motion (~a dozen deals over ~5 years) gets first-class functions.

FunctionRoleVendorProduct
Virtual data room (diligence / fundraising)M&A / LegalPeonyPeony Data Rooms (flat per-admin subscription) · SharePoint convention retained for small tuck-ins
Deal pipeline / target trackingM&A / ExecNotionNotion target database; revisit 4Degrees or Affinity at >25 active targets or first corp-dev hire

Business Ops — Sales, Marketing & Support

FunctionRoleVendorProduct
Sales data / enrichmentSales / PartnershipsClayClay (Growth) — referral-source dev
Cold outreach / deliverabilitySales / PartnershipsReachInboxReachInbox (Growth) — channel/referral outreach
CRM / prospect funnelProduct / GTMBuilt-in + WellSkyProduct-native funnel (HomeCareHQ) + WellSky referral mgmt + BigQuery analytics — no traditional CRM
Product analyticsProduct / GrowthPostHogPostHog Cloud (Boost+, BAA) — analytics + funnels + session replay (content)
Customer support / helpdeskSupport / CXWellSky + built-inWellSky Family Room + product agent + shared inbox — dedicated helpdesk (Front) deferred until volume warrants
Transactional email (product)EngineeringAWSAWS SES (free self-serve BAA; us-west-2, co-located with Neon) — no PHI in bodies
Marketing / lifecycle emailMarketing / Growth⚠️ TBD⚠️ Bake-off: Customer.io vs Iterable vs Loops (BAA gating)
Patient comms — SMS / voiceOps / ClinicalTelnyxTelnyx (shared with product)
Fax (referrals / clinical)Clinical / OpsWellSkyWellSky referral / e-fax intake (AI-summarized); SRFax fallback

Inherited (legacy systems)

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.

FunctionRoleVendorProduct
SOPs & internal knowledge baseOps / All staffTrainual⚠️ Trainual (keep vs migrate to Notion unresolved)
Care management (GCM / ECM)Clinical / OpsCareTree⚠️ CareTree (case management + invoicing, ~$560/mo; disposition unresolved)
Caregiver training (LMS)HR / ClinicalCareAcademy⚠️ CareAcademy (onboarding + compliance training; disposition unresolved)
Office VOIP / phonesOps / All staffRingCentral⚠️ RingCentral (overlap with Teams and Telnyx unresolved)
Virtual assistant staffingOps / HRSphere Rocket⚠️ Sphere Rocket / OLIVIA (disposition unresolved)
Background checks / credentialingHR / ComplianceGuardian (CA DSS)⚠️ Guardian (DOJ/FBI fingerprint clearance; disposition unresolved)

Open & deferred decisions

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.

See also:

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:

📥 Download SPARK-RULES.md

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

  1. Directory Structure: All side effects must live in a src/services/ directory.
  2. 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()).
  3. AI Abstraction: Create an AIService.ts. Wrap window.spark.llm in a typed function.
  4. Auth Abstraction: Create an AuthService.ts. Use a placeholder for user identity that can be swapped for Better Auth.
  5. 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:

StepAction
1. SyncEnsure the Spark project is synced to a standalone GitHub repository.
2. TargetIdentify the target workspace in the Foundation monorepo (e.g., ui/web-app/features/[name]).
3. PortUse 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."

Before merging a ported Spark project, ensure the Pre-Commit Check skill runs clean and a PR Preview Environment has been verified by the submitter.


Self-Consistency Checklist

The Incubation Workflow is consistent with the following Foundation principles:

PrincipleHow it applies
One concern per fileEach 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 CheckPorted 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 EnvironmentEvery 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.


Directory Layout

/
├── ui/
│   ├── web-app/     # Authenticated web application — Vite + TanStack (Router + Query, TypeScript)
│   ├── marketing/   # Marketing site — Next.js 14 (App Router, TypeScript)
│   ├── ios/         # Mobile app — Expo (React Native, TypeScript)
│   ├── types/       # Shared TypeScript type definitions
│   ├── components/  # Shared React component library
│   ├── common/      # Shared TypeScript "business logic" libraries
├── backend/
│   ├── api/         # FastAPI HTTP service (Python)
│   ├── worker/      # Restate durable-execution handlers — durable / agent workflows (Python)
├── schema/          # Drizzle Kit — schema, migrations, RLS policies (single migration history; TypeScript)
├── policies/        # Cerbos authorization policies — app-level PDP that pairs with Neon RLS (YAML)
├── infra/           # Pulumi infrastructure-as-code (TypeScript) — includes restate.ts (Restate Cloud wiring)
├── dbt/             # dbt project — BigQuery adapter (Neon → BigQuery)
├── .github/
│   ├── copilot-instructions.md   # AI assistant context
│   ├── skills/                   # Repository copilot skills
│   └── workflows/                # GitHub Actions CI/CD
├── turbo.json
├── package.json         # Root workspace manifest
├── pnpm-workspace.yaml  # pnpm workspace packages
├── biome.json           # BiomeJS config (JS/TS linting + formatting)
├── lefthook.yml         # Git hooks
└── README.md

turbo.json

Defines pipelines for all monorepo tasks:

PipelinePurposeKey Config
buildCompile all apps and packagesoutputs: [".next/**", "dist/**"]
testRun test suites across all workspacesNo outputs (side-effect only)
lintRun Biome (JS/TS) checksNo outputs
formatAuto-format all codeNo outputs
type-checkTypeScript strict type checkingNo outputs

Remote caching should be configured when available (left as a commented-out TODO in fresh scaffolds).

📥 Download turbo.json template


Root package.json

The root package.json serves as the workspace manifest:

{
  "private": true,
  "packageManager": "pnpm@latest",
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "format": "turbo run format",
    "type-check": "turbo run type-check"
  },
  "devDependencies": {
    "turbo": "latest",
    "@biomejs/biome": "latest",
    "lefthook": "latest"
  }
}

📥 Download root package.json template

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 UnitConfig FileCovers
Web appui/web-app/tsconfig.jsonVite + TanStack app + all source under ui/web-app/
Marketing siteui/marketing/tsconfig.jsonNext.js 14 app + all source under ui/marketing/
Shared packageui/<package-name>/tsconfig.jsonThat package's source (e.g., ui/components, ui/types, ui/common)
Mobile / Expo appui/ios/tsconfig.jsonExpo React Native app + all source under ui/ios/
Schemaschema/tsconfig.jsonDrizzle 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):

// ui/marketing/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src", "next-env.d.ts", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
// ui/ios/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-native",
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src", "app", "*.ts", "*.tsx"],
  "exclude": ["node_modules"]
}
// ui/components/tsconfig.json  (repeated pattern for every shared package)
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "declaration": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

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?

  1. Incremental builds. Only rebuilds what changed. Dramatically speeds up CI.
  2. Parallel execution. Tasks across workspaces run concurrently by default.
  3. Dependency graph awareness. Automatically determines build order based on workspace dependencies.
  4. Remote caching. (When enabled) Shares build artifacts across the team and CI.
  5. 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 Runnot 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

AspectApplications (ui/web-app)Marketing (ui/marketing)
FrameworkVite + TanStack (Router + Query)Next.js 14 (App Router)
LanguageTypeScript (strict)TypeScript (strict)
HostingGCP Cloud RunGCP Cloud Run
AuthBetter Auth (hosted here)None
Data accessDrizzle → Neon (direct Postgres, RLS)None (static / CMS content)
TestingVitest + Testing LibraryVitest + 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 connectionnever 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:

  1. The ui/web-app server issues the Better Auth session and a signed JWT (Better Auth's JWKS endpoint lives here — see above).
  2. 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.
  3. backend/api validates 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

VariableSurfacePublic?Description
DATABASE_URLapp serverNoNeon connection (Better Auth + Drizzle)
BETTER_AUTH_SECRETapp serverNoBetter Auth signing secret
VITE_API_URLappYesFastAPI API base URL (inlined at build)
NEXT_PUBLIC_API_URLmarketingYesBase 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.


Dependencies

Applications (ui/web-app) — runtime

  • vite, @tanstack/react-router, @tanstack/react-query
  • better-auth — auth server + client
  • drizzle-orm + pg — data access (Neon)
  • 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.ts
import 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

AspectChoice
FrameworkExpo SDK (latest stable)
Navigationexpo-router
LanguageTypeScript (strict mode)
AuthBetter Auth (Expo client → the applications-UI server)
TestingVitest (React Native environment) + @testing-library/react-native
Coverage≥ 80%

Auth — Better Auth

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:

export default {
  expo: {
    extra: {
      apiUrl: process.env.EXPO_PUBLIC_API_URL,
      betterAuthUrl: process.env.EXPO_PUBLIC_BETTER_AUTH_URL,
    },
  },
};

Dependencies

  • expo — Expo SDK
  • expo-router — File-based routing
  • better-auth + @better-auth/expo — auth client
  • expo-secure-store — secure token storage
  • posthog-react-native — product analytics + session replay (masking on — see below)

Dev

  • vitest + @testing-library/react-native — component testing (React Native environment)

Analytics — PostHog (posthog-react-native)

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 provider
import { 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.ts
import { 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.ts
import '@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).
  • backend/worker — the Restate durable-execution service: long-running, retriable, human-in-the-loop workflows (durable / agent orchestration).

Directory Structure

backend/
├── api/                     # FastAPI HTTP service
│   ├── src/
│   │   ├── main.py          # FastAPI app entry point
│   │   ├── config.py        # pydantic-settings BaseSettings
│   │   ├── db.py            # Neon (Postgres) engine — SQLAlchemy / asyncpg
│   │   └── routers/
│   │       └── health.py    # GET /health — liveness probe
│   ├── tests/
│   ├── Dockerfile           # Multi-stage build
│   ├── pyproject.toml
│   └── .env.example
└── worker/                  # Restate durable-execution handlers
    ├── src/
    │   ├── main.py          # Restate ASGI app (registers durable handlers)
    │   └── jobs/
    │       └── sync.py      # Durable handler: sync_wellsky_changes
    ├── tests/
    ├── Dockerfile
    ├── pyproject.toml
    └── .env.example

Dependencies

Runtime

PackagePurpose
fastapiHTTP API framework
uvicorn[standard]ASGI server
pydanticData validation
pydantic-settingsSettings from environment variables
sqlalchemyPostgres query layer / ORM (Neon)
asyncpgAsync Postgres driver (Neon)
pyjwt[crypto]Validate Better Auth JWTs (Neon RLS / auth)
polarsDataFrame / data transformation
fireCLI interface generation
google-cloud-bigqueryBigQuery client (analytics sync)

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

PackagePurpose
ruffLinting + formatting
pytestTest runner
pytest-covCoverage reporting
httpxAsync HTTP client for testing FastAPI

Configuration — config.py

All configuration is managed via pydantic-settings BaseSettings:

from pydantic_settings import BaseSettings
 
class Settings(BaseSettings):
    DATABASE_URL: str            # Neon Postgres connection string (branch-scoped)
    BETTER_AUTH_JWKS_URL: str    # Better Auth JWKS endpoint — validates request JWTs
    GCP_PROJECT_ID: str
    BIGQUERY_DATASET: str
    WELLSKY_CLIENT_ID: str
    WELLSKY_CLIENT_SECRET: str

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.requestedemitted 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:

StagePurpose
builderInstall Python dependencies via uv
runtimeCopy only installed packages + source code, run as non-root user

The runtime stage exposes port 8080 (Cloud Run default).


Code Quality

Ruff Configuration (pyproject.toml)

[tool.ruff]
line-length = 100
target-version = "py311"
 
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "S"]
ignore = ["S101"]
 
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S"]
Rule SetPurpose
Epycodestyle errors
Fpyflakes
Iisort (import sorting)
UPpyupgrade
Bflake8-bugbear
Sflake8-bandit (security)

Build Targets

A Makefile or justfile provides standard targets:

TargetCommand
lintruff check src/
formatruff format src/
testpytest --cov --cov-fail-under=80
docker-buildBuild the Docker image

AI Tier

AI Tier — Gemini on Vertex AI

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.

SubcomponentServiceModel / APINotes
Reasoning & agent orchestrationVertex AIGemini 3.1 ProThe agent brain — tool-calling + clinical-context reasoning. Runs as a durable workflow (see Backend).
Fast / high-volumeVertex AIGemini 3.1 Flash / Flash-LiteCheap, high-throughput tasks: classification, extraction, routing, summaries.
Embeddings & retrievalVertex AI + Neongemini-embedding-001 + pgvectorIn-boundary embeddings; vector search lives in Neon under RLS (see Data Pipeline).
Voice / realtimeVertex AIGemini 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:
AgentJobFront door
IntakeConverses with prospect/family, captures care needs, qualifiesFront door of the HomeCareHQ funnel
Care-plannerTurns needs into a proposed care plan (services, hours, caregiver attributes)
SchedulerMatches caregivers (availability, geography, skills), designs + commits the schedule
LaterBilling, 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:

  • Backendbackend/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.


Directory Structure

infra/
├── index.ts          # Main Pulumi program — wires the modules together, stack exports
├── gcp.ts            # Cloud Run (web-app/marketing/api/worker), Load Balancing + Cloud Armor, GCS, Artifact Registry, Secret Manager, WIF, BigQuery
├── restate.ts        # Restate Cloud wiring — environment config + request-signing key (Secret Manager); the runtime itself is managed off-cluster
├── neon.ts           # Neon project (Scale, hipaa:true), branches, roles
├── cloudflare.ts     # DNS records, proxied CDN endpoints
├── tailscale.ts      # Tailscale subnet router (GCE VM) + internal ALB — non-prod tailnet access
├── Pulumi.yaml       # Project configuration
├── Pulumi.dev.yaml   # Dev stack config (placeholder values)
└── package.json      # Pulumi + provider dependencies

State Backend — Pulumi Cloud

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.

StackPurpose
prodProduction GCP + Neon + Cloudflare
devShared 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:

ServiceSourceNotes
web-appui/web-app (Vite + TanStack)Applications UI; hosts Better Auth + Drizzle → Neon
marketingui/marketing (Next.js 14)Public marketing site; no auth, no PHI
apibackend/api (FastAPI)Python HTTP API → Neon
workerbackend/worker (Restate handlers)Durable-execution / async job handlers → Neon; invoked by Restate Cloud over signed HTTPS, suspend-to-zero between steps
SettingValueRationale
maxScale10Cost control; adjust based on load
timeoutSeconds3600worker only — 60-minute ceiling for long WellSky sync steps. web-app / api / marketing keep normal request timeouts (default 300s).
Image sourceArtifact RegistrySame-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:

  1. Project on the Scale plan, region AWS us-west-2 (same metro as Cloud Run us-west1 to minimize cross-cloud egress).
  2. hipaa: true set on the project (irreversible; sets audit_log_level=hipaa). The BAA is self-serve on Scale — no separate add-on.
  3. Extensions: pgvector (embeddings) and pg_session_jwt (Better Auth JWT → RLS auth.user_id()).
  4. 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.
  5. 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:

SurfaceFronted byWhy
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:

  1. Preview / staging Cloud Run services deploy with ingress = internal-and-cloud-load-balancingno public *.run.app URL. They are reachable only through the internal load balancer.
  2. 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.
  3. 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.
  4. 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.


Resource Organization

Following the code organization principle of one concern per file:

FileResources
index.tsPulumi program entry point, module wiring, stack exports
gcp.tsAll GCP resources (Cloud Run, Cloud Load Balancing + Cloud Armor, GCS, Artifact Registry, Secret Manager, WIF, BigQuery)
restate.tsRestate Cloud wiring — environment config + request-signing key (Secret Manager); the runtime is managed off-GCP
neon.tsNeon project (Scale, hipaa:true), branches, roles, extensions
cloudflare.tsDNS records and proxied CDN endpoints

As the infrastructure grows, further split gcp.ts by service (e.g., cloudrun.ts, artifact-registry.ts, bigquery.ts, secrets.ts).

See also:

Data Pipeline

Data Pipeline — Neon → BigQuery (dbt)

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
  1. The app reads/writes Neon for low-latency OLTP.
  2. A scheduled extract-load job copies changed rows from Neon into BigQuery raw_* tables — incrementally, WHERE updated_at > <watermark>.
  3. dbt (BigQuery adapter) transforms raw_*marts within BigQuery.

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.


Directory Structure

dbt/
├── dbt_project.yml
├── profiles.yml.example      # BigQuery target
├── models/
│   ├── staging/
│   │   └── stg_patients.sql          # cleans raw_patients (from the Neon extract)
│   └── marts/
│       └── active_patients.sql       # incremental mart, materialized in BigQuery
└── README.md

jobs/
└── neon_to_bq.py             # extract-load: changed Neon rows → BigQuery raw_*

Extract-Load — neon_to_bq.py

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_at
FROM {{ 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:

SettingValue
Schedule0 3 * * * (3:00 AM UTC daily)
Manual triggerworkflow_dispatch enabled
Stepstrigger the neon-to-bq Cloud Run Job (Neon → BigQuery raw_*) → dbt run --target proddbt test (all BigQuery)
AuthGCP 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

StoreMechanismRetentionRestore
Neon (OLTP · source of truth)Point-in-time restore (PITR) — continuous WAL, restore to any timestamp; plus named branch snapshotsHistory 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 GCS7-day time travel; snapshot / export retention per lifecycleFOR SYSTEM_TIME AS OF, snapshot restore, or reload from GCS
GCS (objects / uploads)Object versioning + lifecycle rulesNon-current versions kept per lifecycle policyRestore a prior object version
Config / IaCPulumi state on Pulumi Cloud (versioned) + Git historyFull historypulumi 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

StoreRPO (max data loss)RTO (max downtime)
Neon (OLTP)≈ 0 — continuous WAL / PITRminutes — branch-at-timestamp
BigQuery (OLAP)≤ 24 h — nightly rebuild from Neonhours — rebuild via pipeline
GCS (objects)≈ 0 — versionedminutes — 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

  1. Identify the target time — the last-known-good timestamp, before the incident.
  2. 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.
  3. Cut over — repoint the app's DATABASE_URL (via GCP Secret Manager) to the restored branch, or promote it to primary.
  4. Rebuild downstream — trigger the extract-load + dbt to rebuild BigQuery from the restored Neon state.
  5. 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.

JobWhat it does
lint-jspnpm biome check --error-on-warnings .
lint-pyruff check backend/ + ruff format --check backend/
test-web-appvitest run --coverage in ui/web-app
test-marketingvitest run --coverage in ui/marketing
test-iosvitest run --coverage in ui/ios
test-apipytest --cov --cov-fail-under=80 in backend/api
test-workerpytest --cov --cov-fail-under=80 in backend/worker
type-checkturbo run type-check
openapi-driftRegenerate 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)
buildturbo 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 branchhotfix/* 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.

WorkflowDeploysPath filter
deploy-api.ymlbackend/api — FastAPI HTTP servicebackend/api/**
deploy-worker.ymlbackend/worker — Restate durable-execution handlersbackend/worker/**
deploy-web-app.ymlui/web-app — Vite + TanStack web appui/web-app/**
deploy-marketing.ymlui/marketing — Next.js marketing siteui/marketing/**

Workload Identity Federation is the required auth method — it eliminates storing GCP service-account keys as GitHub secrets.


PR Preview Environments — pr-env-up.yml / pr-env-down.yml

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.


dbt.yml — Nightly Data Pipeline

Trigger: Schedule 0 3 * * * (3:00 AM UTC) + workflow_dispatch

StepCommand
Authgoogle-github-actions/authWorkload Identity Federation (keyless)
Setuppip install dbt-bigquery
Extract-loadTrigger the neon-to-bq Cloud Run Job (neon_to_bq.py) — incremental Neon → BigQuery raw_*
Rundbt run --target prod (transforms in BigQuery)
Testdbt 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:

.github/workflows/
├── ci.yml               # PR quality gate (lint · Vitest · pytest · type-check · openapi-drift · build)
├── deploy-api.yml       # Cloud Run deploy — backend/api (FastAPI)
├── deploy-worker.yml    # Cloud Run deploy — backend/worker (Restate)
├── deploy-web-app.yml   # Cloud Run deploy — ui/web-app
├── deploy-marketing.yml # Cloud Run deploy — ui/marketing
├── pr-env-up.yml        # Ephemeral PR environment spin-up
├── pr-env-down.yml      # Ephemeral PR environment teardown
└── dbt.yml              # Nightly data pipeline (extract-load Cloud Run Job + dbt run/test on BigQuery)

PR Preview Environments

Ephemeral PR Preview Environments

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

ComponentServiceNaming Convention
DatabaseNeon Branch — off the Staging (non-PHI) projectpr-<number>
Backend APIGCP Cloud Runapi-pr-<number>
Frontend UIGCP Cloud Runweb-app-pr-<number>
OrchestrationGitHub 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.internal only 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

SecretPurpose
GCP_PROJECT_IDGoogle Cloud Project ID
GCP_REGIONTarget region (e.g., us-west1)
NEON_API_KEYNeon API key for branch create/delete + JWKS registration
NEON_PROJECT_IDReference 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:

- uses: neondatabase/create-branch-action@v5
  with:
    project_id: ${{ secrets.NEON_PROJECT_ID }}
    branch_name: pr-${{ github.event.number }}
    api_key: ${{ secrets.NEON_API_KEY }}

Outputs passed to subsequent jobs:

  • Branch Postgres connection string (DATABASE_URL)
  • Branch-scoped Better Auth config (JWKS URL registered against this branch via the Neon API)

Job 2 — Backend Deployment (Python API)

Needs: Job 1

StepDetails
Authenticategoogle-github-actions/auth with Workload Identity Federation
Build + PushDocker image → Artifact Registry
MigrationsApply 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)
Deploygcloud run deploy api-pr-${{ github.event.number }}

Environment variables injected into Cloud Run:

VariableSource
DATABASE_URLJob 1 output (Neon branch)
BETTER_AUTH_JWKS_URLJob 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.

StepDetails
Authenticategoogle-github-actions/auth with Workload Identity Federation
Build + PushDocker image → Artifact Registry — VITE_* values passed as build args (inlined into the client bundle)
Deploygcloud run deploy web-app-pr-${{ github.event.number }} with --ingress=internal-and-cloud-load-balancing (server container, no public URL)
Publish to tailnetAdd 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):

VariableSource
VITE_API_URLJob 2 output (API Cloud Run URL)
VITE_BETTER_AUTH_URLBranch-scoped Better Auth base URL

Runtime environment variables injected into Cloud Run:

VariableSource
DATABASE_URLJob 1 output (the PR's Neon branch) — Better Auth + Drizzle connect here
BETTER_AUTH_SECRETPer-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:

🚀 Preview Environment Ready  (tailnet-only — connect via Tailscale)
- UI:       https://web-app-pr-42.pr.internal
- API:      https://api-pr-42.pr.internal
- Database: Neon branch `pr-42`

Teardown Workflow — pr-env-down.yml

Trigger: pull_request on types [closed] (fires for merged and unmerged PRs).

Cleanup runs in parallel:

Cleanup GCP

# Remove the tailnet host rules + serverless NEGs from the internal ALB first, then the services
gcloud compute url-maps remove-host-rule <internal-lb-url-map> --host=web-app-pr-${{ github.event.number }}.pr.internal --quiet || true
gcloud compute url-maps remove-host-rule <internal-lb-url-map> --host=api-pr-${{ github.event.number }}.pr.internal --quiet || true
gcloud run services delete api-pr-${{ github.event.number }} --region=$GCP_REGION --quiet
gcloud run services delete web-app-pr-${{ github.event.number }} --region=$GCP_REGION --quiet

Cleanup Neon

- uses: neondatabase/delete-branch-action@v3
  with:
    project_id: ${{ secrets.NEON_PROJECT_ID }}
    branch: pr-${{ github.event.number }}
    api_key: ${{ secrets.NEON_API_KEY }}

Deleting the branch also disposes of its isolated auth data and any per-PR secrets.


Application Code Requirements

Web app (Vite + TanStack server)

  1. Dynamic API URLs. All client API calls use VITE_API_URL — never hardcode localhost or a staging domain.
  2. 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)

  1. Dynamic CORS. The CORS middleware accepts requests from the ephemeral web-app-pr-*.run.app domains via a strict, anchored regex:

    allow_origin_regex=r"^https://web-app-pr-\d+\.pr\.internal$"

    [!WARNING] Never use a wildcard (*) for CORS — sessions and tokens must be protected even in PR environments.

  2. 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.


Workflow File Organization

.github/workflows/
├── ci.yml                # PR quality gate (lint, test, type-check, build) — targets dev / release/*
├── deploy-api.yml        # Cloud Run deployment — backend/api (FastAPI), filter backend/api/**
├── deploy-worker.yml     # Cloud Run deployment — backend/worker (Restate handlers), filter backend/worker/**
├── deploy-web-app.yml    # Cloud Run deployment — ui/web-app (Vite), filter ui/web-app/**
├── deploy-marketing.yml  # Cloud Run deployment — ui/marketing (Next.js), filter ui/marketing/**
├── pr-env-up.yml         # Ephemeral PR environment spin-up
├── pr-env-down.yml       # Ephemeral PR environment teardown
└── dbt.yml               # Nightly Neon → BigQuery dbt sync

See also:

Secure PHI GitOps

Secure PHI GitOps — PHI / non-PHI Separation via Neon Branching

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:
    1. Live GCP production application instances.
    2. Authorized CI/CD runner identities (Workload Identity Federation; Neon IP Allow).
    3. 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)
DataMasked / synthetic — no PHIReal PHI
Branch triggerfeature/*devrelease/*, hotfix/*main
Who connectsAny dev, localhostProd app · CI runners · Coder-in-subnet only
Ephemeral branchpr-<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 (maindev) 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)

  1. Trigger: PR opened against dev.
  2. Create a branch on the Neon Staging project named pr-<number> (neondatabase/create-branch-action).
  3. Run Drizzle Kit migrations against the Staging branch; post the neondatabase/schema-diff-action comment as the review gate.
  4. Run application tests.
  5. Teardown: on PR merge/close, delete the Neon Staging branch.
  6. Post-merge: run migrations on Staging main to update the baseline schema future forks branch from.

Workflow B — Release / Hotfix Verification (release/* / hotfix/*main)

  1. Trigger: PR opened against main (branch matches release/* or hotfix/*).
  2. Create a branch on the Neon Production project named release-<version> (or hotfix-<id>).
  3. Spin up secure CI compute (in-boundary runner) and connect to the Neon Prod branch.
  4. Immediately execute pending Drizzle migrations.
  5. Run integration / E2E tests against the migrated real-data-shape schema.
  6. 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.
  7. Teardown: on merge to main, delete the Neon Prod branch.

Workflow C — Automated Backmerge (maindev)

  1. Trigger: push / merge on main.
  2. Attempt a fast-forward or clean merge of main into dev.
  3. On success: push the updated dev.
  4. 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)

  1. Trigger: weekly schedule (e.g. Sunday 02:00).
  2. Take a logical dump of Neon Productioninside the boundary.
  3. Pipe the dump through a HIPAA-capable masking / synthetic-data tool (e.g., Neosync — OSS, anonymization + synthetic; alternatives: Tonic, postgresql-anonymizer, Snaplet seed).
  4. Restore the masked dump into the Neon Staging project, giving developers realistic data volumes and shapes with zero PHI.
  5. 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

BucketDataOverlay base (read-through)Writable byRetention
…-uploads-prod (CMEK)real PHI— (authoritative)prod app onlyversioned / lifecycle
…-uploads-phi-previewPHI, per-branch…-uploads-prod (read-only)release/* / hotfix/* CI + Coder-in-subnetshort TTL (~7d) + teardown
…-uploads-stagingmasked / synthetic— (base)Workflow D refreshrolling
…-uploads-nonphi-previewmasked / synthetic, per-branch…-uploads-staging (read-only)feature/* CI + any devshort TTL + teardown

Each preview branch writes under its own prefix: …-preview/<branch>/<key>.

Overlay semantics (copy-on-write)

  • Read key: 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.
  • Write key: always the preview bucket at <branch>/<key>. New uploads and overwrites land in the branch's own layer.
  • Delete key: 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

ConcernMechanism
PHI never on a laptop / in a modelLocal dev → Staging (non-PHI) project only
PHI debugging is possible but containedCoder in a private GCP subnet (reached over the tailnet), in-boundary tooling, no clipboard/downloads
Feature branches can't leak PHIEphemeral branches come off the masked Staging project, never prod
Release tested against real shapesrelease/* verifies on a Production Neon branch, in-boundary
Blobs follow the same PHI boundaryPer-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 blobsIAM: read-only on the base, write only on the preview bucket; non-PHI preview has no prod-bucket access
Schema/RLS always reviewedCODEOWNERS on the Drizzle path + Code-Owner review on main/dev
Coding-agent choice is unconstrainedCompliance weight lives only in the Coder boundary, not the everyday IDE

See also:

Developer Tooling

Developer Tooling

Every Foundation project ships with a fully reproducible development environment. No manual tool installation after the initial workspace build.


Toolchain Overview

ToolRole
DevboxNix-backed declarative package manager for all CLIs and runtimes
direnvAuto-activates the Devbox environment on cd into the project
process-composeOrchestrates all dev services in a single TUI terminal
CaddyLocal reverse proxy with auto-generated internal TLS certificates
Dev Container / CoderContainer image + lifecycle hooks for Nix/Devbox/dependency install
BiomeJS/TS linting + formatting (replaces ESLint + Prettier)
RuffPython linting + formatting (replaces flake8 + black + isort)
LefthookGit hooks (replaces Husky + lint-staged)
actRun GitHub Actions workflows locally via Docker

Devbox — devbox.json

Devbox provides a reproducible Nix-backed shell with all system-level tools:

Packages (Nix)

git, curl, wget, jq, yq, gnumake, direnv, lefthook, uv, nodejs_22, gh, process-compose, docker, docker-compose, ripgrep, fd, bat, eza, zsh, pulumi-bin, google-cloud-sdk, caddy, netcat-openbsd, iputils, dnsutils, traceroute, openssl, unzip, htop, tree, file, vim

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:

[ -n "${BASH_VERSION:-}" ] && command -v direnv >/dev/null 2>&1 && eval "$(direnv hook bash)" 2>/dev/null || true
export NVM_DIR="${HOME}/.nvm"
[ -s "${NVM_DIR}/nvm.sh" ] && . "${NVM_DIR}/nvm.sh" --no-use || true
 
# act installed via curl installer so it persists at /usr/local/bin/act
if ! command -v act >/dev/null 2>&1; then
  curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | bash -s -- -b /usr/local/bin
fi

Scripts

ScriptPurpose
setupOne-command bootstrap: project deps, Oh My Zsh + pnpm plugin + zsh-autosuggestions + zsh-syntax-highlighting, direnv zsh hook, tmux alias, Lefthook hooks
devStart full local stack via process-compose up
dev:appStart the applications UI (Vite + TanStack) dev server only
dev:marketingStart the marketing site (Next.js) dev server only
dev:apiStart FastAPI dev server only
dev:dbStart the local Postgres fallback (docker/devbox) — offline use only; the default is a per-developer Neon dev branch
test:*Run tests per service
lint:*Run linters per service
format:*Run formatters per service
hooks:installInstall Lefthook git hooks
ci:localRun the ci.yml quality-gate workflow locally via act

The devbox.lock file must be committed to ensure identical Nix store paths for all developers.

Example devbox.json
{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.13.7/.schema/devbox.schema.json",
  "packages": [
    "git@latest",
    "curl@latest",
    "wget@latest",
    "jq@latest",
    "yq@latest",
    "gnumake@latest",
    "direnv@latest",
    "lefthook@latest",
    "uv@latest",
    "nodejs_22@latest",
    "gh@latest",
    "process-compose@latest",
    "docker@latest",
    "docker-compose@latest",
    "ripgrep@latest",
    "fd@latest",
    "bat@latest",
    "eza@latest",
    "zsh@latest",
    "pulumi-bin@latest",
    "google-cloud-sdk@latest",
    "caddy@latest",
    "netcat-openbsd@latest",
    "iputils@latest",
    "dnsutils@latest",
    "traceroute@latest",
    "openssl@latest",
    "unzip@latest",
    "htop@latest",
    "tree@latest",
    "file@latest",
    "vim@latest"
  ],
  "env": {
    "DATABASE_URL": "postgresql://<user>:<pass>@<your-neon-dev-branch>.neon.tech/app?sslmode=require",
    "API_URL": "http://localhost:8000",
    "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
    "DEVBOX_COREPACK_ENABLED": "true"
  },
  "shell": {
    "init_hook": [
      "# Activate direnv integration only for bash shells.",
      "# devbox run invokes init_hook via /usr/bin/sh, and direnv has no 'sh' hook target.",
      "[ -n \"${BASH_VERSION:-}\" ] && command -v direnv >/dev/null 2>&1 && eval \"$(direnv hook bash)\" 2>/dev/null || true",
      "",
      "# Source NVM so that node/pnpm from nvm is available in devbox shells",
      "# || true is required: under set -e (prepended by devbox to hooks.sh), a",
      "# failing [ -s ] && . exits the script if NVM isn't installed yet.",
      "export NVM_DIR=\"${HOME}/.nvm\"",
      "[ -s \"${NVM_DIR}/nvm.sh\" ] && . \"${NVM_DIR}/nvm.sh\" --no-use || true",
      "",
      "# Install act (local GitHub Actions runner) via the official installer if not present.",
      "# act is not in nixpkgs because the Nix build sandbox conflicts with Docker-in-Docker;",
      "# the curl installer places the binary at /usr/local/bin/act which survives devbox shell restarts.",
      "if ! command -v act >/dev/null 2>&1; then",
      "  echo '==> Installing act (local GitHub Actions runner)...'",
      "  curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | bash -s -- -b /usr/local/bin >/dev/null 2>&1 && echo '  → act installed.' || echo '  → Warning: act install failed.'",
      "fi"
    ],
    "scripts": {
      "setup": [
        "#!/usr/bin/env bash",
        "set -eu",
        "",
        "# ── Project dependencies & first-time setup ──────────────────────────────",
        "echo '==> Running project setup (deps, env files, git hooks)...'",
        "bash scripts/setup.sh",
        "",
        "# ── Oh My Zsh ────────────────────────────────────────────────────────────",
        "echo '==> Configuring Oh My Zsh...'",
        "",
        "if [ ! -d \"${HOME}/.oh-my-zsh\" ]; then",
        "  echo '  → Installing Oh My Zsh...'",
        "  if ! RUNZSH=no CHSH=no sh -c \"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\"; then",
        "    echo '  → Warning: Oh My Zsh install failed; continuing setup'",
        "  fi",
        "fi",
        "",
        "ZSH_CUSTOM=\"${ZSH_CUSTOM:-${HOME}/.oh-my-zsh/custom}\"",
        "",
        "if [ ! -d \"${ZSH_CUSTOM}/plugins/zsh-autosuggestions\" ]; then",
        "  echo '  → Installing zsh-autosuggestions...'",
        "  git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions \"${ZSH_CUSTOM}/plugins/zsh-autosuggestions\"",
        "fi",
        "",
        "if [ ! -d \"${ZSH_CUSTOM}/plugins/zsh-syntax-highlighting\" ]; then",
        "  echo '  → Installing zsh-syntax-highlighting...'",
        "  git clone --depth=1 https://github.com/zsh-users/zsh-syntax-highlighting \"${ZSH_CUSTOM}/plugins/zsh-syntax-highlighting\"",
        "fi",
        "",
        "if [ ! -d \"${ZSH_CUSTOM}/plugins/pnpm\" ]; then",
        "  echo '  → Installing oh-my-zsh pnpm plugin...'",
        "  git clone --depth=1 https://github.com/ntnyq/omz-plugin-pnpm \"${ZSH_CUSTOM}/plugins/pnpm\"",
        "fi",
        "",
        "# ── Configure .zshrc ─────────────────────────────────────────────────────",
        "ZSHRC=\"${HOME}/.zshrc\"",
        "[ ! -f \"${ZSHRC}\" ] && touch \"${ZSHRC}\"",
        "",
        "if grep -q '^ZSH_THEME=' \"${ZSHRC}\" 2>/dev/null; then",
        "  sed -i 's/^ZSH_THEME=.*/ZSH_THEME=\"agnoster\"/' \"${ZSHRC}\"",
        "else",
        "  printf 'ZSH_THEME=\"agnoster\"\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "if grep -q '^plugins=' \"${ZSHRC}\" 2>/dev/null; then",
        "  sed -i 's/^plugins=.*/plugins=(git docker python node pnpm history direnv zsh-autosuggestions zsh-syntax-highlighting)/' \"${ZSHRC}\"",
        "else",
        "  printf 'plugins=(git docker python node pnpm history direnv zsh-autosuggestions zsh-syntax-highlighting)\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "if ! grep -q '# foundation-devbox-zsh' \"${ZSHRC}\" 2>/dev/null; then",
        "  printf '\\n# foundation-devbox-zsh\\n' >> \"${ZSHRC}\"",
        "  printf 'export NVM_DIR=\"$HOME/.nvm\"\\n' >> \"${ZSHRC}\"",
        "  printf '[ -s \"$NVM_DIR/nvm.sh\" ] && . \"$NVM_DIR/nvm.sh\"\\n' >> \"${ZSHRC}\"",
        "  printf 'ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE=\"fg=60\"\\n' >> \"${ZSHRC}\"",
        "  printf 'ZSH_AUTOSUGGEST_STRATEGY=(history completion)\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "if ! grep -q '# foundation-direnv-zsh-hook' \"${ZSHRC}\" 2>/dev/null; then",
        "  printf '\\n# foundation-direnv-zsh-hook\\n' >> \"${ZSHRC}\"",
        "  printf 'command -v direnv >/dev/null 2>&1 && eval \"$(direnv hook zsh)\"\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "if ! grep -q '# foundation-tmux' \"${ZSHRC}\" 2>/dev/null; then",
        "  printf '\\n# foundation-tmux\\n' >> \"${ZSHRC}\"",
        "  printf '# Use repo .tmux.conf when TMUX_CONF is set (populated by .envrc).\\n' >> \"${ZSHRC}\"",
        "  printf 'if [ -n \"${TMUX_CONF:-}\" ]; then\\n' >> \"${ZSHRC}\"",
        "  printf \"  alias tmux='tmux -f \\\"\\$TMUX_CONF\\\"'\\n\" >> \"${ZSHRC}\"",
        "  printf 'fi\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "echo '==> Zsh configuration complete!'"
      ],
      "hooks:install": "lefthook install",
      "dev": "process-compose up",
      "dev:app": "cd ui/web-app && pnpm dev",
      "dev:marketing": "cd ui/marketing && pnpm dev",
      "dev:api": "cd backend/api && uv run uvicorn --app-dir src main:app --reload --port 8000",
      "dev:db": "docker compose -f docker-compose.db.yml up",
      "test:api": "cd backend/api && uv run pytest",
      "test:app": "cd ui/web-app && pnpm test",
      "lint:app": "cd ui/web-app && pnpm dlx @biomejs/biome check ./src",
      "format:app": "cd ui/web-app && pnpm dlx @biomejs/biome format --write ./src",
      "lint:api": "cd backend/api && uv run ruff check src/",
      "ci:local": "act pull_request --container-architecture linux/amd64 -W .github/workflows/ci.yml"
    }
  }
}

📥 Download devbox.json template


Local Database — Neon dev branch (default)

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.

Example .envrc
# Load devbox environment (generates shell env from devbox.json)
eval "$(devbox generate direnv --print-envrc)"
 
# ── Auto-bootstrap Python venv & deps for backend/api ────────────────────────
if [ ! -d backend/api/.venv ]; then
  echo "direnv: creating backend/api/.venv ..."
  (cd backend/api && uv venv --quiet)
fi
if [ -f backend/api/uv.lock ] && [ backend/api/uv.lock -nt backend/api/.venv/.direnv-synced ] \
   || [ ! -f backend/api/.venv/.direnv-synced ]; then
  echo "direnv: running uv sync in backend/api/ ..."
  (cd backend/api && uv sync --all-extras --quiet) && touch backend/api/.venv/.direnv-synced
fi
 
# ── Auto-bootstrap Node deps for ui/web-app ────────────────────────────────────
if [ -f ui/web-app/package.json ] && [ ! -d ui/web-app/node_modules ]; then
  echo "direnv: running pnpm install in ui/web-app/ ..."
  (cd ui/web-app && pnpm install --no-fund --reporter=silent)
fi
 
# ── Activate Python venv so pytest / CLI tools are on PATH ────────────────────
export VIRTUAL_ENV="$PWD/backend/api/.venv"
PATH_add "$VIRTUAL_ENV/bin"
 
# ── Add ui/web-app/node_modules/.bin so vitest / biome are on PATH ─────────────
PATH_add ui/web-app/node_modules/.bin
 
# ── Project-level environment variables ──────────────────────────────────────
export API_URL="${API_URL:-http://localhost:8000}"

📥 Download .envrc template (rename to .envrc after download)


process-compose — process-compose.yml

Orchestrates all development services:

ProcessCommandNotes
web-apppnpm dev (in ui/web-app/)Vite + TanStack dev server
marketingpnpm dev (in ui/marketing/)Next.js dev server
apiuv run uvicorn ... (in backend/api/)FastAPI dev server
otel-collectordocker compose -f docker-compose.otel.yml upLocal 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.
caddycaddy run --config Caddyfile --watchLocal 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.

📥 Download process-compose.yml template


Caddy — Local Reverse Proxy

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 server
local.example.com {
	tls internal
	reverse_proxy localhost:3000 {
		header_up Host localhost:3000
	}
}
 
# App + API  –  app.local.example.com
app.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.

📥 Download Caddyfile template


Lefthook — lefthook.yml

Pre-commit (parallel)

HookGlobCommand
biome-check*.{js,jsx,ts,tsx,json}pnpm biome check --apply {staged_files}
ruff-format*.pyruff format {staged_files}
ruff-lint*.pyruff check --fix {staged_files}

Pre-push

HookCommand
test-jspnpm turbo run test --filter=[HEAD^1] (each package runs vitest run)
test-pypytest --cov --cov-fail-under=80 in both backend/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)

📥 Download lefthook.yml template


Biome — biome.json

SettingValue
LinterEnabled: recommended + suspicious + correctness groups
FormatterindentStyle: "space", indentWidth: 2, lineWidth: 100
Import sortingorganizeImports.enabled: true
Ignorenode_modules, .next, dist, coverage, .expo

Biome replaces both ESLint and Prettier with a single, faster tool.

📥 Download biome.json template


Ruff — pyproject.toml

See Backend for the full Ruff configuration.

Ruff replaces flake8, isort, black, and bandit with a single tool. Rule sets: E, F, I, UP, B, S.


Dev Container & Coder Workspace

.devcontainer/devcontainer.json

SettingValue
Base imagemcr.microsoft.com/devcontainers/base:ubuntu-22.04
remoteUserroot (envbuilder compatibility)
onCreateCommand.devcontainer/on-create.sh
postCreateCommanddevbox run setup (logged to /tmp/postCreate.log)
postStartCommandRun .devcontainer/post-start.sh (restart Nix daemon, wait for Docker)
remoteEnvDOCKER_BUILDKIT=1, SHELL=/bin/zsh

VS Code extensions pre-installed: Devbox, direnv, Ruff, Python/Pylance, Biome, Tailwind CSS, Copilot.

Example devcontainer.json
{
  "name": "Foundation Project",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu-22.04",
  // Run all devcontainer lifecycle commands as root, matching the user that
  // envbuilder / Coder run as inside the container.
  "remoteUser": "root",
  // NOTE: docker-in-docker is intentionally omitted.
  // envbuilder does not support the `privileged` or `init` feature properties
  // that docker-in-docker requires. Docker CE is installed by on-create.sh to
  // provide the daemon; Docker CLI + Compose may come from Devbox or Docker CE.
  // In Coder workspaces the Docker socket (/var/run/docker.sock) should be
  // mounted from the host via the workspace template.
  // Installs nix (single-user, sandbox disabled for Docker), devbox, and all
  // project packages. Nix installation is required; failures will stop the build.
  "onCreateCommand": "bash .devcontainer/on-create.sh",
  // postCreateCommand runs devbox run setup which installs pnpm/Python deps.
  // Output is tee'd to /tmp/postCreate.log; on failure the log is cat'd so it
  // appears in docker logs / envbuilder output for easy debugging.
  "postCreateCommand": "bash -c 'devbox run setup 2>&1 | tee /tmp/postCreate.log; exit ${PIPESTATUS[0]}'",
  // postStartCommand runs every time the container starts (including restarts).
  // It runs the post-start script, which restarts nix-daemon and waits for
  // Docker. Docker must be provided by a host-mounted socket.
  "postStartCommand": "bash -lc 'bash .devcontainer/post-start.sh'",
  "remoteEnv": {
    "DOCKER_BUILDKIT": "1",
    "SHELL": "/bin/zsh"
  },
  "customizations": {
    "vscode": {
      "settings": {
        "terminal.integrated.defaultProfile.linux": "zsh",
        "terminal.integrated.fontFamily": "'MesloLGS NF', 'Hack Nerd Font Mono', 'Courier New', monospace",
        "terminal.integrated.profiles.linux": {
          "zsh": { "path": "/bin/zsh", "args": ["-l"] },
          "bash": { "path": "/bin/bash", "args": ["-l"] }
        },
        "editor.formatOnSave": true,
        "python.defaultInterpreterPath": "${workspaceFolder}/backend/api/.venv/bin/python",
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff"
        },
        "[typescript]": {
          "editor.defaultFormatter": "biomejs.biome"
        },
        "[typescriptreact]": {
          "editor.defaultFormatter": "biomejs.biome"
        },
        "[javascript]": {
          "editor.defaultFormatter": "biomejs.biome"
        },
        "[javascriptreact]": {
          "editor.defaultFormatter": "biomejs.biome"
        },
        "[json]": {
          "editor.defaultFormatter": "biomejs.biome"
        }
      },
      "extensions": [
        "jetify-com.devbox",
        "mkhl.direnv",
        "charliermarsh.ruff",
        "ms-python.python",
        "ms-python.vscode-pylance",
        "biomejs.biome",
        "bradlc.vscode-tailwindcss",
        "GitHub.copilot",
        "GitHub.copilot-chat",
        "ms-vscode.makefile-tools",
        "redhat.vscode-yaml",
        "timonwong.shellcheck"
      ]
    },
    "codespaces": {
      "openFiles": ["README.md"]
    }
  },
  "forwardPorts": [3000, 5173, 8000, 5432, 8080, 4443],
  "portsAttributes": {
    "3000": { "label": "Next.js Marketing", "onAutoForward": "notify" },
    "5173": { "label": "Vite + TanStack App", "onAutoForward": "notify" },
    "8000": { "label": "FastAPI", "onAutoForward": "notify" },
    "5432": {
      "label": "Local Postgres (offline fallback)",
      "onAutoForward": "silent",
      "requireLocalPort": true
    },
    "8080": {
      "label": "Caddy HTTP",
      "onAutoForward": "silent"
    },
    "4443": {
      "label": "Caddy HTTPS",
      "onAutoForward": "silent"
    }
  }
}

📥 Download devcontainer.json template

.devcontainer/on-create.sh

Idempotent bootstrap script:

  1. Ensure Docker is installed — uses get.docker.com convenience installer (Codespaces-aware: skips install if already present, warns without failing if daemon unreachable)
  2. Initialize git submodules (non-fatal if credentials unavailable)
  3. Install zsh if absent
  4. Install tmux via apt (host-OS integration; not from Nix)
  5. Configure Nix: sandbox = false, filter-syscalls = false (heredoc append, idempotent)
  6. Install Nix (Determinate Systems: --init none, --no-confirm)
  7. Start Nix daemon (sleep 3 for socket readiness)
  8. Install Devbox
  9. devbox install
  10. Append Nix/Devbox PATH to ~/.bashrc + ~/.zshrc

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 pipefail
 
IS_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
  fi
fi
 
if ! 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
  fi
fi
 
# ── 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 zsh
fi
 
# ── 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 tmux
fi
 
# ── 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/nix
if [ ! -f /etc/nix/nix.conf ] || ! grep -q 'sandbox = false' /etc/nix/nix.conf; then
  cat >> /etc/nix/nix.conf <<'EOF'
sandbox = false
filter-syscalls = false
EOF
fi
 
# ── 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 script
if [ -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.sh
fi
 
# ── 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 -- -f
fi
 
# ── 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 reachable
DEVBOX_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"

📥 Download on-create.sh template

.devcontainer/post-start.sh

Runs on every container start (including restarts). Keeps idempotent tasks that don't survive a stop/start cycle:

  1. Restart nix-daemon (no systemd in devcontainers; daemon doesn't survive stop/start)
  2. 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
  fi
fi
 
# 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 1
done

📥 Download post-start.sh template

Root Dockerfile (Devbox image)

Based on jetpackio/devbox:latest. Pre-populates the Nix store and optimizes layer size:

FROM jetpackio/devbox:latest
COPY devbox.json devbox.lock ./
RUN devbox run -- echo "Installed Packages."
RUN nix-store --gc && nix-store --optimise
CMD ["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 actWith act
Push → wait for GitHub Actions queueRun locally in seconds
Expensive feedback loop for CI changesIterate on workflow YAML offline
No offline supportWorks 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 invocation
act pull_request --container-architecture linux/amd64 -W .github/workflows/ci.yml
 
# Run a single job
act pull_request -j lint-js -W .github/workflows/ci.yml
 
# List all available jobs across all workflows
act --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 .gitignore
DATABASE_URL=...
NEON_API_KEY=...
GCP_PROJECT_ID=...

Then pass it to act:

act pull_request --secret-file .secrets -W .github/workflows/ci.yml

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 workflow
act 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):

-P ubuntu-latest=catthehacker/ubuntu:act-22.04
--container-architecture linux/amd64

📥 Download .actrc template (rename to .actrc after download)

.actrc vs committed config

.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 TypeInstall Method
System CLIs & runtimesdevbox.json packages (nixpkgs)
Python packagesuv sync (via devbox run setup)
Node.js packagespnpm 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.tsxButton.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.

✅ Prefer❌ Avoid
components/Button.tsx — one component per filecomponents/index.tsx containing Button, Card, Modal
components/Button.test.tsx — co-located test__tests__/components.test.tsx testing many components
hooks/useAuth.ts — one hook per filehooks/index.ts with multiple hook definitions

Re-export from barrel files:

// components/index.ts
export { Button } from "./Button";
export { Card } from "./Card";

Backend — backend/api (HTTP) & backend/worker (durable)

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.

✅ Prefer❌ Avoid
routers/health.py, routers/patients.py (backend/api)routers.py with all route handlers
models/patient.py, models/appointment.pymodels.py with every Pydantic model
jobs/sync.py, jobs/notify.py (backend/worker)jobs.py with all durable handlers

Schema — schema/ (Drizzle)

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:

schema/
├── src/
│   ├── tables/          # one Drizzle table (concern) per file
│   │   ├── patient.ts
│   │   └── appointment.ts
│   └── rls.ts           # crudPolicy / authenticatedRole RLS definitions
├── drizzle.config.ts    # Drizzle Kit config
└── migrations/          # generated SQL — one migration history
✅ Prefer❌ Avoid
tables/patient.ts, tables/appointment.ts — one table per filetables.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 filepolicies.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:

  1. Zero conflicts. Two agents adding two different components create two different files.
  2. Smaller PRs. Each change touches only the files it needs to.
  3. Easier reviews. Human and automated reviewers see focused, relevant diffs.
  4. 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 logging
 
class 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/api and backend/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:

ServiceImagePort
OTel Collectorotel/opentelemetry-collector-contrib:latest4317 (gRPC), 4318 (HTTP)
Jaeger (optional local trace viewer)jaegertracing/all-in-one:latest16686 (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/worker OTEL_EXPORTER_OTLP_ENDPOINT defaults to it (http://localhost:4317).

OTel Collector Configuration

An otel-collector-config.yml stub is provided:

  • Dev: debug (console) exporter + optional otlp → Jaeger.
  • Prod: googlecloud exporter (or OTLP → telemetry.googleapis.com).

Data Flow

FastAPI App (OTel SDK)
    → OTLP (gRPC/HTTP)
    → OTel Collector
    → Cloud Trace / Cloud Monitoring / Cloud Logging   (prod: telemetry.googleapis.com)
    → console / local Jaeger                            (dev)

Dashboards

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.

ServiceRoleRule
Durable execution — Restate Cloud (managed)Async / agent orchestrationNo 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.
PostHogProduct analytics / flags / experiments / session replay / error tracking / LLM observability + evalsBAA-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.
SanityMarketing / CMS contentMarketing + in-app copy only. Never store PHI.
Neon Data API (PostgREST)Alt data accessOutside the HIPAA boundary — no PHI path may use it. Use a direct Postgres connection for anything touching PHI.
Telnyx SMS bodiesNotifications / 2FAKeep 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 & documentationNo 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.
Sales & internal tools — Clay, ReachInbox, Microsoft Teams, Linear, NotionCRM / comms / tickets / notes / meeting notesNever 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:

VendorPlan / ConfigBAA
GCP (Cloud Run, BigQuery, GCS, Secret Manager)Standard✅ Google BAA — verify project coverage
NeonScale 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
PostHog (product analytics, flags, experiments, session replay, error tracking, LLM observability + evals)PostHog Cloud on the Boost+ platform package; enable session-replay PHI masking + scrub error/LLM payloads✅ Self-serve BAA at app.posthog.com/legal — BAA lands on Boost+. Keep PHI out of flag keys, event names, property keys, error payloads, and LLM traces
WellSkyCore EHR / referral management / e-fax / Family Room — holds real PHI✅ BAA required (core PHI system). WellSky↔Neon sync runs in backend/worker
AWS SESTransactional (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

ControlStatusDetails
PHIScrubFilterStub (must implement)Attached to root logger; blocks all logs until PHI scrubbing is implemented
SecretsMust configureGCP Secret Manager (runtime) + Pulumi ESC (declarative). No DB-resident vault. See Secret Management
Neon RLSMust enableRow-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 controlsMust reviewRestrict dataset access to authorized service accounts; de-identify PHI in the warehouse or keep the dataset inside the BAA
Cloud Run service accountMust configureLeast-privilege principle
Cloud Run env varsMust reviewNo PHI in build logs or container env
Audit loggingMust enableNeon 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.

See also:

Libraries

Canonical Library Reference

These are the standard libraries for all Foundation projects. Do not substitute without explicit justification and team approval.


Python

LibraryPurpose
fastapiHTTP API framework
uvicorn[standard]ASGI server
pydanticData validation and serialization
pydantic-settingsSettings management from environment variables
sqlalchemyPostgres query layer / ORM (Neon)
asyncpgAsync Postgres driver (Neon)
pyjwt[crypto]Validate Better Auth JWTs (Neon RLS / auth)
polarsDataFrame / data transformation
fireCLI interface generation
google-cloud-bigqueryGoogle BigQuery client (analytics sync)
google-genaiGoogle 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-sdkDurable-execution client (Restate, 1.0) — ASGI/Hypercorn, some glue vs FastAPI-native
cerbosAuthorization PDP client — checks + Query Plan API (SQL filters that pair with Neon RLS)

Python Dev / Test

LibraryPurpose
ruffLinting + formatting
pytestTest runner
pytest-covCoverage reporting
httpxAsync HTTP client (FastAPI test client)

JavaScript / TypeScript

LibraryPurpose
viteBuild tool for the application UIs
@tanstack/react-routerApplication routing
@tanstack/react-queryApplication data fetching / server state
openapi-typescript + openapi-fetchTyped API client generated from the FastAPI OpenAPI schema (the frontend ↔ backend contract)
better-authAuthentication (hosted by the applications-UI server)
drizzle-orm + pgTyped data access — direct Postgres → Neon, with RLS
drizzle-kitSchema 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)
nextMarketing site framework (Next.js 14, App Router)
expo + expo-routerMobile framework (React Native)
posthog-jsProduct analytics + session replay on ui/web-app (client; masking on)
posthog-nodeServer-side PostHog for flags (local eval) + events — ui/web-app server and ui/marketing
posthog-react-nativeProduct analytics + session replay on ui/ios (masking on)
turboMonorepo build system (Turborepo)

JS/TS Dev / Test

LibraryPurpose
@biomejs/biomeLinting + formatting
lefthookGit hooks
vitestTest runner (ui/web-app, ui/marketing, ui/ios)
@testing-library/react + @testing-library/react-nativeReact / React Native component testing

Substitution Policy

These libraries were chosen for:

  1. Ecosystem fit — they work well together and with the rest of the stack.
  2. Performance — e.g., Polars over Pandas for data processing, Biome over ESLint for speed.
  3. Maintenance — actively maintained with strong community support.
  4. Compliance — compatible with HIPAA requirements and vendor BAAs.

If a project requires a different library:

  • Document the justification in the PR description.
  • Update .github/copilot-instructions.md to reflect the change.
  • Ensure the replacement meets the same quality and compliance standards.

Environment Variables

Environment Variables — Master Reference

Every environment variable used across all services, its purpose, and where it should be stored.


Variable Reference

VariableService(s)Secret?Storage Location
DATABASE_URLweb-app, api, workerYesGCP Secret Manager (Neon connection string)
BETTER_AUTH_SECRETweb-appYesGCP Secret Manager
BETTER_AUTH_JWKS_URLapi, workerNoCloud Run env / GitHub Actions variable
VITE_API_URLweb-appNoDocker build arg (inlined at build time)
NEXT_PUBLIC_API_URLmarketingNoDocker build arg (inlined at build time)
EXPO_PUBLIC_API_URLiosNoBuild-time public config
GCP_PROJECT_IDapi, worker, infraNoGitHub Actions variable
BIGQUERY_DATASETworkerNoGitHub Actions variable
WELLSKY_CLIENT_IDworkerYesGCP Secret Manager
WELLSKY_CLIENT_SECRETworkerYesGCP Secret Manager
OTEL_EXPORTER_OTLP_ENDPOINTapi, workerNoGitHub Actions variable / Cloud Run env
RESTATE_* (runtime endpoint + identity/signing keys)workerYesGCP Secret Manager

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.


PR Environment Secrets

Additional secrets required for ephemeral PR preview environments:

VariableSecret?Storage Location
GCP_PROJECT_IDNoGitHub Actions variable
GCP_REGIONNoGitHub Actions variable
NEON_API_KEYYesGitHub Actions secret
NEON_PROJECT_IDNoGitHub Actions variable

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:

LocationVariables
ui/web-app/.env.exampleDATABASE_URL, BETTER_AUTH_SECRET, VITE_API_URL
ui/marketing/.env.exampleNEXT_PUBLIC_API_URL
backend/api/.env.exampleDATABASE_URL, BETTER_AUTH_JWKS_URL
backend/worker/.env.exampleDATABASE_URL, BETTER_AUTH_JWKS_URL, BIGQUERY_DATASET, WELLSKY_CLIENT_ID, WELLSKY_CLIENT_SECRET, OTEL_EXPORTER_OTLP_ENDPOINT, RESTATE_* (runtime endpoint + identity keys)
Root .env.exampleDev-time defaults for process-compose

Security Rules

  1. Never commit secrets to the repository. Use .gitignore to exclude .env files.
  2. Never log secret values. The pydantic-settings BaseSettings class prevents accidental logging. Never log DATABASE_URL.
  3. Use the VITE_ / NEXT_PUBLIC_ / EXPO_PUBLIC_ prefixes only for values safe to expose in client-side JavaScript.
  4. Rotate secrets regularly and audit access logs. Add a new Secret Manager version, then destroy the old one after cutover.

See HIPAA Compliance for additional security requirements.

Content Management

Content Management — Decoupled Marketing Stack

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.


Overview

ConcernTechnologyRole
Content storageSanity.ioHeadless CMS / Content Lake (marketing + in-app copy — never PHI)
Product analytics & experimentationPostHogOne 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.
Marketing frontendNext.js 14 on GCP Cloud RunSEO-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:

// sanity/schemas/heroSection.ts
export default {
  name: 'heroSection',
  title: 'Hero Section',
  type: 'document',
  fields: [
    { name: 'headline', title: 'Headline', type: 'string' },
    { name: 'subheadline', title: 'Subheadline', type: 'text' },
    { name: 'ctaLabel', title: 'CTA Button Label', type: 'string' },
    { name: 'ctaUrl', title: 'CTA Button URL', type: 'url' },
    { name: 'backgroundImage', title: 'Background Image', type: 'image' },
  ],
}

Visual Editing & Live Previews

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-lived PostHog 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.

Server-Side Variant Assignment (Next.js Middleware)

Next.js middleware intercepts every request before it reaches the React rendering layer, running inside the ui/marketing Cloud Run service. The middleware:

  1. Reads (or sets) a stable visitor ID cookie (ph_visitor_id).
  2. Calls the PostHog SDK with the visitor ID to evaluate which experiment (feature-flag) variant the visitor qualifies for.
  3. 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.ts
import { 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:

// sanity/schemas/heroSection.ts (extended for experiments)
{
  name: 'experimentVariant',
  title: 'Experiment Variant Key',
  type: 'string',
  description: 'PostHog experiment / feature-flag variant key (e.g. "control", "hero-b")',
}

The GROQ query filters on this field at request time:

*[_type == "heroSection" && experimentVariant == $variant][0] {
  headline,
  subheadline,
  ctaLabel,
  ctaUrl,
  backgroundImage
}

4. Component Wiring: Sanity + PostHog

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:

  1. Sanity data — Replace hard-coded strings with props fetched via GROQ queries in the parent Server Component (see the placeholder pattern below).
  2. 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 together
import { 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.tsx
interface 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.ts
export 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.

Environment Variables

VariableRequiredDescription
NEXT_PUBLIC_SANITY_PROJECT_IDProd onlySanity project ID
NEXT_PUBLIC_SANITY_DATASETProd onlySanity dataset (e.g. production)
SANITY_API_TOKENProd onlyServer-side Sanity read token (for draft previews)
POSTHOG_KEYProd onlyPostHog project API key
POSTHOG_HOSTProd onlyPostHog Cloud host URL (e.g. https://us.i.posthog.com)
POSTHOG_PERSONAL_API_KEYProd onlyPersonal 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.

Secret Management

Secret Management — Pulumi ESC + GCP Secret Manager

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:

// infra/ — conceptual: ESC env → Secret Manager resources
const env = esc.getEnvironment({ organization, project, environment: "prod" });
for (const [name, value] of Object.entries(env.secrets)) {
  const secret = new gcp.secretmanager.Secret(name, { secretId: name, replication: { auto: {} } });
  new gcp.secretmanager.SecretVersion(`${name}-v`, { secret: secret.id, secretData: value });
}

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).

Requirements

  • Per-env isolation: prod / staging / preview-pr-* / dev.
  • Never log DATABASE_URL or any secret value. PHI-adjacent keys stay inside the boundary.
  • Rotation + audit trail (HIPAA): Secret Manager versions + GCP Cloud Audit Logs record every access.
  • Keyless CI: GitHub Actions authenticates to GCP via Workload Identity Federation — never store a GCP service-account JSON as a secret.

Quick Reference

ConcernLocal DevelopmentProduction / Preview
Source of truthPulumi ESC (pulumi env open)Pulumi ESC
Runtime store.env (git-ignored)GCP Secret Manager (mounted into Cloud Run)
Neon DATABASE_URLlocal branch / local PGSecret Manager (prod); minted per-PR, destroyed with the branch (preview)
RotationNew Secret Manager version; destroy old after cutover

GitHub Copilot Skills

GitHub Copilot Skills

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 skillWith a skill
Agent guesses the right lint/test commandsAgent runs the exact commands from lefthook.yml
Inconsistent fix strategies across sessionsDeterministic, documented procedure every time
Silent failures when a step is skippedRequired steps are explicit; status table always reported

Available Skills

SkillTrigger phrasePurpose
pre-commit-check"run pre-commit checks", "fix lint errors", "green-light this branch"Mirrors lefthook hooks; auto-fixes what it can; reports remaining issues

pre-commit-check

File: .github/skills/pre-commit-check/SKILL.md

📥 Download template

What it does

Mirrors the lefthook hooks exactly, then auto-fixes anything fixable, then reports remaining issues that require manual attention.

HookToolScopeAuto-fixable?
pre-commitBiomeJS check --writeui/ TS/TSX/JS/CSS✅ Yes
pre-commitRuff check --fix + formatapi/ Python✅ Yes
TypeScript tsc --noEmitui/web-app, ui/marketing❌ Manual fix required
pre-pushpytestbackend/api❌ Manual fix required
pre-pushVitestui/web-app❌ Manual fix required

When to invoke it

Use this skill before committing, before opening a PR, or any time you want to confirm a branch is clean:

  • "Run pre-commit checks"
  • "Fix lint errors"
  • "Fix type errors"
  • "Green-light this branch"
  • "Pre-push check"

Procedure summary

  1. Identify scope — check git status for changed packages; honor any argument passed (e.g. "api").
  2. BiomeJS auto-fixpnpm biome check --write . from the repo root, then verify with pnpm biome check ..
  3. Ruff auto-fixuv run ruff check --fix . && uv run ruff format ., then verify.
  4. TypeScript type-check (web-app)cd ui/web-app && pnpm exec tsc --noEmit. Fix errors manually.
  5. TypeScript type-check (marketing)cd ui/marketing && pnpm exec tsc --noEmit. Fix errors manually.
  6. pytestcd backend/api && uv run --extra dev pytest. Never delete tests to make them pass.
  7. Vitestcd ui/web-app && pnpm test.
  8. Final verification — re-run all checks; every command must exit with code 0.

Output

The skill always ends with a status table:

CheckStatusNotes
BiomeJS lint/format✅ / ❌Remaining manual-fix items
Ruff lint/format✅ / ❌Remaining manual-fix items
TypeScript — web-app✅ / ❌Error count
TypeScript — marketing✅ / ❌Error count
pytest✅ / ❌Passed / total
Vitest✅ / ❌Passed / total

Adding a New Skill

  1. Create .github/skills/<skill-name>/SKILL.md in the target repository.

  2. Add the required YAML front matter:

    ---
    name: <skill-name>
    description: '<one-line description + USE FOR: trigger phrases>'
    argument-hint: 'Optional: ...'
    ---
  3. Write the procedure using numbered steps with exact shell commands.

  4. End with a reporting section (status table or structured output).

  5. 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.
  • Compute: POST /api/pricing{ grandTotalMonthly, grandTotalAnnual, perPersonMonthly, hipaaPremiumMonthly, perCategory[], perVendor[], lines[], headcount }.
  • Excel workbook: POST /api/pricing/export.xlsx (same request body) → multi-sheet .xlsx.
  • Live static data: GET /api/pricing/roles.json (role ids + tiers), GET /api/pricing/scenarios.json (worked examples).
  • MCP over HTTP: POST /api/mcp (Streamable HTTP) — tools: price_stack, get_pricing, list_services, get_architecture_doc.
  • In-repo: calculateStackCost(request) in src/lib/pricing/stack-calculator.ts; CLI pnpm pricing '<json>' and pnpm pricing --xlsx '<json>' [out.xlsx]; MCP tool price_stack.

Request body — mau is required, hipaa defaults true, role ids come from /api/pricing/roles.json:

{ "composition": { "software-engineer": 2, "caregiver": 100 }, "mau": 15000, "hipaa": true, "overrides": { "github": { "billTier": "office" }, "clay": { "disabled": true } } }

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.

VendorServiceFunctionWhat it doesPricing modelEst. cost / mo
GitHub (Microsoft)GitHub TeamSource control (code repo)Repository hosting, code review, CODEOWNERS enforcement, 3,000 CI/CD minutes$4 / user / mo$4 / user
↳ Copilot BusinessCoding agent (in-editor)AI code completions, agent-mode PR generation, IP indemnity$19 / user / mo$19 / user
↳ ActionsCI / CDCI/CD minutes beyond the 3,000 included in Team$0.008 / min (Linux) — included minutes usually sufficient~$0
↳ CodespacesRemote dev environments (managed alt)On-demand cloud dev environments (4-core default)Usage — ~$0.36 / hr + $0.07 / GB storage~$18
MicrosoftMicrosoft 365 (Business Premium)Office suite / email / calendar + identity & security planeOutlook, Teams, SharePoint, OneDrive + the security bundle: Entra ID P1, Intune (MDM), Defender for Business (EDR), Purview (DLP/labels)$22 / user / mo$22 / user
↳ TeamsTeam chatChat, channels, meetings — bundled in M365 Business Premium$0 incremental$0
↳ Entra ID P1Workforce identity / SSO / MFASSO, MFA, Conditional Access — bundled in M365 (escalate to P2 for ID Protection / PIM as you grow)Bundled in M365$0
↳ IntuneDevice management (MDM)Mobile-device / endpoint management — bundled in M365Bundled in M365$0
↳ Defender for BusinessEndpoint security / EDREndpoint detection & response — bundled in M365Bundled in M365$0
↳ PurviewData governance / DLP / labels + compliance / GRCDLP, sensitivity labels, and Compliance Manager (GRC / audit) — bundled in M365; replaces a separate Vanta/DrataBundled in M365$0
GoogleGCPCloud platformCloud Run (applications UI, marketing, API, worker), BigQuery, Artifact Registry, Secret Manager, Workload Identity FederationUsage-based~$50 *
↳ Cloud Armor + Cloud Load BalancingEdge / WAF (app/API PHI edge)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-basedusage *
↳ GCP-native observabilityObservability + backend error trackingCloud Logging, Cloud Trace, Cloud Monitoring, Error Reporting (Cloud Run backend error tracking) — OpenTelemetry → GCP backends, inside the Google BAAUsage — 50 GiB / mo logs free, then ~$0.50 / GiB~$0 *
↳ Vertex AI (Gemini)AI — reasoning / embeddings / voiceManaged Gemini models for product reasoning, embeddings (pgvector retrieval) and voiceUsage-based (per token / request)usage *
Google Workspace (Essentials Starter)Office suite (field / ops secondary)Drive, Docs, Meet collaboration; no corporate Gmail seat neededFree$0
NeonNeon (Scale)OLTP database + vector searchServerless PostgreSQL — branch-per-PR, autoscaling, pgvector. HIPAA on Scale: set hipaa:true + self-serve BAA, no separate add-onUsage-based — ~$0.26 / CU-hr compute + storage, no monthly minimum~$15 *
AnthropicClaude (Team Standard)Coding agent (primary) + AI assistantAI assistant for writing, analysis, code review — 200K context$25 / user / mo$25 / user
LinearLinear (Business)Project / issue trackingIssue tracking, sprint planning, triage — the ticket system that drives the AI-native workflow$16 / user / mo$16 / user
NotionNotion (Business)Docs / knowledge base + business PM + meeting notes + M&A deal pipelineInternal wiki, runbooks, company knowledge base, business PM (tasks / milestones), M&A deal-pipeline / target database, and AI Meeting Notes (bundled — replaces Granola)$20 / user / mo$20 / user
FigmaFigma (Professional)Design / prototypingDesign + prototyping — the design-system & brand source of truth, paired with AI-in-code prototyping$15 / editor / mo$15 / editor
SanitySanity.io (Growth)Marketing CMSHeadless CMS / Content Lake — structured content for the marketing site (Next.js on Cloud Run) + in-app copy$15 / seat / mo$15 / seat
PostHogPostHog (Cloud, Boost+)Product analytics + flags / experiments + session replay + client error tracking + LLM observabilityOne BAA-covered vendor that consolidates analytics, feature flags, experiments, session replay, client-side error tracking (web + Expo/React Native) and LLM evals — backend/Cloud Run errors go to GCP Error Reporting (above)Usage-based (events + replays) — Boost+ platform package carries the BAA~$50–100 *
CloudflareCloudflare (Pro)DNS / DDoS / CDN (marketing edge)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 *
TailscaleTailscaleVPN / secure accessTailnet — SSO-gated WireGuard mesh for access to infra, preview environments, and internal services$6 / user / mo (builder + office)$6 / user
PulumiPulumi CloudInfrastructure as code + secrets (ESC)IaC state management, CI/CD drift detection, Pulumi ESC secretsIndividual free (1 user); Team $40 / mo flat (≤ 10 users); Enterprise $400 / mo (unlimited)~$0–$4 *
RestateRestate Cloud (managed)Durable executionDurable-execution runtime for background jobs and the agentic, human-in-the-loop agent loop — managed serverless runtime (us) driving Cloud Run scale-to-zero handlersManaged service — free tier: 50k actions/mo, then usage-based~$0 *
CerbosCerbos (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 RLSOpen-source, ~$0 license — cost ≈ underlying GCP compute~$0–3 *
CoderCoder (Community — self-hosted)Remote dev environmentsSelf-hosted cloud dev workspaces on GCP — the primary development environmentOSS, no license — pay only for the underlying GCP infra~$25 / dev *
TelnyxTelnyxTelephony — SMS / voiceProgrammable voice, SMS / MMS, SIP trunking — customer + patient notifications, support lines, 2FA, product voiceUsage-based — voice ~$0.002 / min, SMS ~$0.004 / msg, DIDs ~$1 / number / mo~$50 *
ClayClay.com (Growth)Sales data / enrichmentData enrichment & prospecting automation — unlimited seats, credit-based$446 / mo flat (unlimited users)~$149 *
ReachInboxReachInbox (Growth)Cold outreach / deliverabilityAI-powered cold email sequencing with unlimited sending accounts$99 / mo flat~$33 *
WellSkyWellSky Personal CareHome-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
AWSAWS SESTransactional email (product)Product transactional email (us-west-2, co-located with Neon) — free self-serve BAA, no PHI in bodiesUsage — ~$0.10 / 1,000 emails~$0–1 *
1Password1Password (Business)Password / secrets manager (people)Team password vaults, secrets sharing, SSO integration$7.99 / user / mo~$8 / user
SageSage IntacctFinance / accountingCore accounting / GL, AP / AR, financial reportingQuote-based (annual contract)Quote
RampRampCorporate cards / expenseCorporate cards, expense management, bill payFree (interchange-funded)$0
DropboxDropbox Sign (Standard)E-signatureE-signature with BAA (annual)$15–25 / user / mo (Standard, annual)~$15 / user
PeonyPeony Data RoomVirtual 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.

StewardProductFunctionCost
TanStack (OSS)Vite + TanStack (Router + Query)Web application UI$0
Vercel (OSS)Next.jsMarketing site$0
Expo (OSS)Expo + React NativeMobile app$0
Better Auth (OSS)Better Auth (self-hosted)Product auth$0
Open sourceFastAPI (Python)Backend API$0
Open sourceOpenAPI → openapi-typescriptAPI contract$0
Drizzle (OSS)Drizzle KitSchema & migrations$0
dbt Labsdbt (on BigQuery)Data transform (ELT)$0 (OSS core)
Vercel (OSS)TurborepoMonorepo / build$0
Astral / BiomeBiome (TS) · Ruff (Py)Lint / format$0
Open sourceLefthookGit hooks$0
Jetify (OSS)Devbox + direnv + process-composeLocal 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.

FunctionCandidatesOwnerStatus
Enterprise productivity AIM365 Copilot vs Gemini for WorkspaceAll staff⚠️ Bake-off
Marketing / lifecycle emailCustomer.io vs Iterable vs LoopsMarketing / Growth⚠️ TBD (BAA gating)
Caregiver scheduling AI (on WellSky)Zingage vs Phoebe vs AldenOps / Eng⚠️ TBD
Product voice / telephonyTelnyx + 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.

VendorServiceFunctionWhat it doesBase platform feePer-employee / moPer-contractor / mo
RipplingRippling (Core HR + Payroll)HR / payroll / benefitsUS payroll, benefits admin (device management migrates to Intune)$35–$40 / mo$20
DeelDeel (Global)Global payroll / EOR / contractorsInternational payroll, contractor management, EORFree 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.

ComponentWhat it isSpecEst. cost / mo
Control planeCoder dashboard, provisioner, workspace metadata1 × e2-medium VM (2 vCPU / 4 GB)~$25
Dev workspacePrimary development machine (one per developer)4-core / 16 GB spot instance (GCP e2-standard-4) + 50 GB SSD~$25 / dev
GPU add-on (optional)Beefy GPU for direct AI / ML development — not every developer needs thisRunPod A100 80 GB VRAM, ~40 hrs / mo on-demand~$55 / dev

GitHub Codespaces (managed alternative)

ServiceWhat it doesPricing modelEst. cost / user / mo
GitHub CodespacesManaged cloud dev environments integrated with GitHubUsage — ~$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.

CategoryServices includedCost
Engineering platformGitHub (Team + Copilot) × 3, Neon, GCP, GCP-native observability, Pulumi (free), Cloudflare$184
Project management & knowledgeLinear × 3, Notion Business × 3, Figma × 3 (editors)$153
Productivity & identityM365 Business Premium × 3 (Office + Teams + Entra P1 + Intune + Defender + Purview, all bundled)$66
AI & productivityClaude × 3$75
Sales & outreachClay, ReachInbox$545
TelephonyTelnyx (light usage)$50
Content & experimentationSanity × 3 ($45); PostHog (usage, light — ~$50)$95
HR & payrollRippling (base + 3 employees)$100
Dev environmentsCoder control plane + 3 dev machines (4-core spot)$100
Grand total~$1,368 / mo
Per person~$456 / mo

Team of 20

At twenty people, light specialization begins. Not everyone needs every tool, but overlap is still high.

Assumed department split:

DepartmentHeadcountWhat they need
C-Suite3Everything
Product3Everything
Engineering8Everything
Sales / Marketing4Notion, Communication, Sales & Outreach, AI & Productivity, HR
Operations / Support2Communication, Identity, HR only
CategoryWho uses it (seats)Cost
Engineering platform14 × GitHub + Copilot; Neon; GCP; GCP-native observability; Pulumi Team ($40 flat); Cloudflare$532
Project management & knowledge14 × Linear; 18 × Notion Business; 5 × Figma (editors)$659
Productivity & identity18 × M365 Business Premium (office/clinical/eng/sales); 2 × Google Workspace Essentials (field/ops — free)$396
AI & productivity18 × Claude$450
Sales & outreachClay, ReachInbox$545
TelephonyTelnyx (moderate usage)$150
Content & experimentation5 × Sanity ($75); PostHog (usage, moderate — ~$75)$150
HR & payrollRippling (base + 20 employees)$440
Dev environmentsCoder control plane + 8 dev machines (4-core spot)$225
Grand total~$3,547 / mo
Per person~$177 / mo

Team of 100

At a hundred people the company has distinct departments. Not every role needs an engineering seat or a sales tool.

Assumed department split:

DepartmentHeadcountWhat they need
C-Suite5Everything
Product10Everything
Engineering30Everything
Sales / Marketing25Notion, Communication, Sales & Outreach, AI & Productivity, HR
Field / Operations30Communication, Identity, HR only
CategoryWho uses it (seats)Cost
Engineering platform45 × GitHub + Copilot; Neon; GCP; GCP-native observability; Pulumi Team ($40 flat); Cloudflare$1,921
Project management & knowledge45 × Linear; 70 × Notion Business; 12 × Figma (editors)$2,300
Productivity & identity70 × M365 Business Premium (office/clinical/eng/sales); 30 × Google Workspace Essentials (field/ops — free)$1,540
AI & productivity70 × Claude$1,750
Sales & outreachClay, ReachInbox$545
TelephonyTelnyx (higher volume)$500
Content & experimentation15 × Sanity ($225); PostHog (usage, higher volume — ~$100)$325
HR & payrollRippling (base + 100 employees)$2,040
Dev environmentsCoder 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:

ServiceWhat it doesWhen to adoptPricing 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 ScaleHigh-availability or heavier OLTP / branch-per-PR volumeUsage-based with higher included limits (contact sales for committed-use discounts)
Microsoft Entra ID (P2)Identity Protection, PIM, access reviews, lifecycle governancePrivileged-access governance / regulated-industry audit requirements$9 / user / mo
PostHog (Scale / Enterprise platform)Advanced permissions, SSO/SAML, audit logs, higher included volume, dedicated support — layered on the same BAA-covered CloudLarge product org running heavy analytics / many concurrent experiments / high replay volumeUsage-based + platform add-on (contact sales)
Datadog or New RelicAdvanced APM, infrastructure monitoring, real-user monitoring — complements the GCP-native stackWhen OpenTelemetry + Cloud Monitoring alone can't keep up with multi-region, multi-service sprawlUsage-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 isolationCustom pricing — annual per-user
Lightdash or Wren AIGoverned BI / conversational analytics on top of your data warehouseWhen stakeholders need self-serve reporting beyond raw SQLLightdash: 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

FunctionRoleVendorProduct
Office suite / email / calendarOffice / clinical staff (primary); field / ops (secondary)Microsoft (primary)Microsoft 365 Business Premium (primary — office/clinical staff); Google Workspace (field / ops)
Enterprise productivity AIAll staffMicrosoft / Google⚠️ Bake-off — M365 Copilot vs Gemini for Workspace (undecided)
Team chatAll staffMicrosoftMicrosoft Teams (bundled with M365)
Project management (business)All staff / OpsNotionNotion — business task + milestone tracking for business objectives (distinct from Linear, which drives product/eng)
Workforce identity / SSO / MFAIT / AllMicrosoftMicrosoft Entra ID (P1 → P2 as you grow)
Device management (MDM)ITRippling → MicrosoftRippling device management initially (inherited, Pro plan) → Microsoft Intune later
Endpoint security / EDRIT / SecurityMicrosoftMicrosoft Defender for Business
Data governance / DLP / labelsIT / ComplianceMicrosoftMicrosoft Purview
Password / secrets manager (people)All staff1Password1Password

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.


FunctionRoleVendorProduct
HR / payroll / benefitsHR / OpsRipplingRippling Core HR + Payroll
Finance / accountingFinanceSageSage Intacct
Corporate cards / expenseFinanceRampRamp
E-signatureLegal / OpsDropboxDropbox Sign (Standard, annual + BAA)
Compliance / GRC (HIPAA program)Compliance / Security⏸ DeferredVanta / Drata — deferred to Q1 2027 (pre-go-live)
Legal / contracts / entity mgmtLegalOutside counselNo dedicated tooling yet
Business insuranceOps / FinanceSpecialist brokerHome-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.

FunctionRoleVendorProduct
Virtual data room (diligence / fundraising)M&A / LegalPeonyPeony Data Rooms (flat per-admin subscription); a SharePoint-folder convention is retained for small tuck-ins
Deal pipeline / target trackingM&A / ExecNotionNotion 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

FunctionRoleVendorProduct
Sales data / enrichmentSales / PartnershipsClayClay (Growth) — referral-source development
Cold outreach / deliverabilitySales / PartnershipsReachInboxReachInbox (Growth) — channel/referral outreach
CRM / prospect funnelProduct / GTMBuilt-in + WellSkyProduct-native funnel (HomeCareHQ) + WellSky referral mgmt + BigQuery analytics — no traditional CRM
Product analyticsProduct / GrowthPostHogPostHog Cloud (Boost+, BAA) — product analytics + feature flags + experiments + session replay + error tracking + LLM observability & evals
Customer support / helpdeskSupport / CXWellSky + built-inWellSky Family Room + product agent + shared inbox — dedicated helpdesk (Front) deferred until volume warrants
Transactional email (product)EngineeringAWSAWS SES (free self-serve BAA; us-west-2, co-located with Neon) — no PHI in email bodies
Patient comms — SMS / voiceOps / ClinicalTelnyxTelnyx (shared with the product voice stack)
Fax (referrals / clinical)Clinical / OpsWellSkyWellSky 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: