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