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
| Package | Purpose |
|---|---|
fastapi | HTTP API framework |
uvicorn[standard] | ASGI server |
pydantic | Data validation |
pydantic-settings | Settings from environment variables |
sqlalchemy | Postgres query layer / ORM (Neon) |
asyncpg | Async Postgres driver (Neon) |
pyjwt[crypto] | Validate Better Auth JWTs (Neon RLS / auth) |
polars | DataFrame / data transformation |
fire | CLI interface generation |
google-cloud-bigquery | BigQuery 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
| Package | Purpose |
|---|---|
ruff | Linting + formatting |
pytest | Test runner |
pytest-cov | Coverage reporting |
httpx | Async HTTP client for testing FastAPI |
Configuration — config.py
All configuration is managed via pydantic-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: strNever 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/apivalidates the caller's Better Auth JWT against the JWKS (BETTER_AUTH_JWKS_URL) and then sets that JWT on the Neon connection/transaction — Neon'spg_session_jwtextension plus theauthenticatedrole expose the claims to SQL, so RLS policies keyed onauth.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/apicalls the PDP per request (during HTTP request authorization) via the PythoncerbosSDK. The policies live in a version-controlled, top-levelpolicies/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-appgenerates a typed client intoui/web-app/src/lib/api/withopenapi-typescript(types) +openapi-fetch(a tiny typed fetch wrapper), via apnpm gen:apiscript. 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.ymlre-runspnpm gen:apiand fails the build on any diff betweenopenapi.jsonand the committed types, so the contract can never silently rot.
This is the "schema-first, typed contract" the AI-native workflow relies on: an agent that edits a FastAPI route regenerates the client and immediately sees every frontend call that no longer type-checks — the contract is enforced at build time, not discovered at runtime.
Durable / async jobs — backend/worker (Restate)
Long-running and event-driven work runs in backend/worker on the Restate durable-execution layer (retriable, resumable steps) — not inline in the API's request handlers. The worker's durable handlers (in backend/worker/src/jobs/) are registered as a deployment with Restate Cloud via the Restate ASGI app in main.py; Restate Cloud then invokes, journals, and re-drives them on retry, calling the worker's signed public endpoint. Example: sync_wellsky_changes (backend/worker/src/jobs/sync.py):
- Trigger:
wellsky/sync.requested— emitted bybackend/api(e.g. from a WellSky webhook route) and delivered to the handler by Restate Cloud. - Steps: Fetch changes from WellSky → upsert into Neon → sync analytics rows to BigQuery
- PHI discipline: event payloads carry opaque, short-lived IDs only — never stable DB primary keys — and the function fetches PHI from Neon inside the boundary. Short-lived IDs are the primary entity identifier so the metadata Restate does hold can't become a durable re-identification vector. This keeps PHI out of the orchestration layer regardless of vendor.
Durable-execution engine: Restate Cloud (managed). The engine must push work over HTTP and suspend to zero on Cloud Run (pull-based polling-worker models are disqualified). We chose Restate over Inngest because the product's core is agentic, long-running, human-in-the-loop workflows (chat → design a care schedule → implement → pause for a human kickoff call → activate). Restate's virtual objects + durable promises make "await a human" and suspend-to-zero first-class — exactly this shape — where Inngest's one-shot waitForEvent was the weaker fit. It runs on Restate Cloud (managed, serverless, us region): Restate Cloud drives execution and calls the worker's signed public Cloud Run endpoint, and the ASGI/HTTP handlers scale to zero between steps — so there is no always-on node to operate. The Python SDK is restate-sdk (1.0); it is ASGI (Hypercorn), so there is a little glue vs FastAPI-native.
PHI: the initial posture is that no PHI is shared with Restate Cloud at all — there is no Restate BAA. Restate journals more than a plain function runner — ctx.run results, call args/results, awakeable payloads, and virtual-object state (ctx.set) — so any PHI that must be journaled is encrypted at the serde boundary inside the handler via a GCP-KMS-backed encrypting Serde (use a typed PHI[T] wrapper whose serde always encrypts), and Restate Cloud's journal and state hold ciphertext only — Restate Cloud never sees plaintext PHI. Because the serde does not encrypt keys, the primary entity identifiers are opaque, short-lived IDs (not stable DB primary keys), so object keys, state-key names, error messages/exceptions, and trace IDs stay PHI-free and non-correlatable. Restate Cloud therefore acts as an encrypted conduit and needs no vendor BAA; if the product later chooses to share PHI with Restate, that decision requires a signed BAA first. The rationale lives in the Compute Stack Plan (Notion).
HIPAA: PHI (patient names, MRNs, DOBs) must never appear in log output on Cloud Run / Cloud Logging, nor in durable-job payloads. See the HIPAA Compliance page.
Dockerfile
Multi-stage build optimized for Cloud Run:
| Stage | Purpose |
|---|---|
builder | Install Python dependencies via uv |
runtime | Copy only installed packages + source code, run as non-root user |
The runtime stage exposes port 8080 (Cloud Run default).
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 Set | Purpose |
|---|---|
E | pycodestyle errors |
F | pyflakes |
I | isort (import sorting) |
UP | pyupgrade |
B | flake8-bugbear |
S | flake8-bandit (security) |
Build Targets
A Makefile or justfile provides standard targets:
| Target | Command |
|---|---|
lint | ruff check src/ |
format | ruff format src/ |
test | pytest --cov --cov-fail-under=80 |
docker-build | Build the Docker image |