Status: Accepted Date: 2026-05-31 Deciders: Jon (owner); reviewed/converged by claude-1, claude-2 (cc2), codex. Supersedes: the auth-on-Cloudflare-Workers line in Decisions (living ledger) and the edge framing in Cloudflare stack proposal.

Context

The login/register auth slice was built and proven live on Cloudflare Pages edge (Better Auth + Drizzle adapter + @neondatabase/serverless, Kysely-free; smoke passed against live Neon + real OAuth). Two questions then forced a topology decision before hardening that to production or building the Track-2 onboarding wizard:

  1. Backend language & home — surfaced because Cloudflare Pages cannot run FastAPI, and because blockdata shares components with kai-chattr (which keeps a working Python backend: ConPTY/Win32 PTY, agent workspace). Jon resolved the product backend language as Python-primary: blockdata is the writing-system migration, the Python backend already exists and carries over, and TypeScript still gets a clean typed seam through OpenAPI-generated clients.
  2. Where the backend runs — edge (Workers/Pages Functions, JS-only) vs a persistent container host that can run both a Node Better Auth service and Python.

Forces at play: the migration's purpose is clean, sustainable architecture (no foundational-dependency patches, no edge-only shortcuts taken just because they are easier); cross-repo compatibility is wanted for the chat interface, agent tools, and the Letta-derived memory framework; secrets are SOPS-only; one shared Neon instance. Critically, blockdata is writing-system migrated — a working Python/FastAPI backend already exists and is being carried over.

Decision

Four locks (Jon, #newchat id=313, with the API-language correction at id=319):

  1. Auth-schema ownership = A (Alembic sole-DDL). Alembic remains the single DDL authority over the entire Neon DB; Better Auth's migrator stays disabled; Drizzle is a typed runtime view only. Single source of truth beats removing a small hand-sync. Reversible to B (Better Auth/Drizzle owns a dedicated auth.* Postgres schema) later via a migration if BA schema churn ever justifies it — not a rewrite.
  2. App/domain API = Python-primary (FastAPI). This is a Type-1 decision (sets the repo's domain runtime for its life), owner-decided and final. Blockdata is the migrated writing-system backend — the Python/FastAPI code already exists and is carried over, not translated. Python-primary matches the locks already in place (Alembic sole-DDL, SQLAlchemy + SQLAdmin), and FastAPI remains the single OpenAPI source of truth — the frontend consumes typed TypeScript clients generated from that OpenAPI, so the FE seam stays typed without a second backend runtime. Better Auth stays a bounded Node service; Python owns the domain API and the Docling/doc-AI layer. kai-chattr also keeps Python.
  3. Host = Fly.io. Was provisional pending a P0.5 go/no-go spikeRESULT 2026-06-01: GO, Fly CONFIRMED (see below). The spike decided, not preference.
  4. Compatibility = converge the four SEAMS, not the runtimes. Shared across blockdata + kai-chattr: (1) one TypeScript chat/agent-UI package (v3 design canon), (2) one owned extracted-Letta memory service (Python) consumed over MCP/HTTP — our extracted package run as a service, not the upstream Letta project as a dependency, (3) one MCP agent-tool contract, (4) the auth JWT/JWKS contract. Per-repo domain runtimes may differ.

Target topology: static Vite/React on Cloudflare Pages (CDN) + a bounded Node Better Auth service (issues sessions/JWT, publishes JWKS) + the Python/FastAPI domain API and Docling/doc-AI layer, the two backend processes co-located on one Fly app talking over localhost with stateless JWT/JWKS between them. Frame this as "two co-located processes + a CDN," not microservices — collision is structurally impossible under decision (1), and there is no service mesh.

Options Considered

Auth-schema ownership

OptionToolsTrade-off
A — Alembic sole-DDL (chosen)one migratorsingle source of truth; accept a small recurring BA→Alembic hand-sync (stable core + a jwks table for the JWT plugin)
B — Better Auth owns auth.* schematwo migratorsremoves the hand-sync; can't collide (separate schema) but adds a second migrator

Collision was never a differentiator (Drizzle is a runtime view, not a migrator). A wins on single-source-of-truth; B remains a cheap migration away if churn justifies it.

App/domain API language

OptionAssessment
Python/FastAPI-primary (chosen)reuses the existing migrated writing-system backend (no translation); matches the Alembic + SQLAlchemy + SQLAdmin locks; FastAPI stays the OpenAPI source of truth and emits typed TypeScript clients to the frontend; Python also hosts the Docling/doc-AI layer
TypeScript app/domain API rewritewould translate a working Python backend for no benefit, and duplicate what the OpenAPI-to-TypeScript-client seam already provides — rejected

Host

OptionAssessment
Fly.io (chosen, provisional)runs Node + Python persistently against Neon; SOPS-delegated token verified live
Cloudflare edge onlycannot run FastAPI; rejected as an edge-only shortcut
Railway / Render / VMviable; not selected — Fly token already provisioned and verified

P0.5 spike — go/no-go gate (entry condition for P1)

P1 (Fly foundation) MUST NOT begin until this passes. The spike proves, on Fly Machines:

  1. A minimal Node Better Auth service boots, reaches Neon over the Fly network, and serves /api/auth/* + a JWKS endpoint.
  2. A minimal FastAPI process co-located in the same Fly app validates a Better Auth JWT via JWKS over localhost.
  3. SOPS-injected secrets reach the Fly runtime (sops exec-env secrets/dev/fly.yaml 'flyctl ...'), no plaintext.

Go → proceed to P1 on Fly. No-go → record the failure and re-decide the host (Railway/Render/VM) before P1. The spike decides.

Result (2026-06-01, cc2): GO. Proven on a Fly Machine (org personal) + a throwaway Neon branch (since torn down): a co-located Node Better Auth (pg/kysely, jwt plugin) + FastAPI process in one container. A public FastAPI /spike endpoint returned {"signup_ok":true,"jwt_issued":true,"jwks_validated":true,"verdict":"GO"} — BA email sign-up wrote a user to Neon (Fly→Neon egress ✓), the jwt plugin minted a JWT via /api/auth/token ✓, and FastAPI validated it against the localhost /api/auth/jwks with PyJWT/EdDSA ✓. DATABASE_URL + BETTER_AUTH_SECRET were injected as Fly secrets from SOPS (stdin import, no plaintext) ✓. Learning: Better Auth's standard pg/kysely setup works fine on a Node runtime — the kysely bundling failure was specific to the Cloudflare Pages edge and does not recur off-edge; no adapter swap or patch is needed for the Node auth service. Proven skeleton kept at E:\fly-p05-spike (outside the repo) as the P1 seed.

Contract amendment plan (what changes, in which phase)

Encoded incrementally so no contract ever describes un-built state:

ContractAmendmentPhase
dataNo changemigration-authority already encodes A. Reaffirmed.P0 (done)
backend-apiauth-session rule flips: hosting = Node Better Auth service on Fly (not CF Pages Function/Worker edge); validation seam = JWT/JWKS. The openapi rule is unchanged — domain API stays Python-primary (FastAPI) and FastAPI remains the single OpenAPI source of truth. Staged as openItems now; the auth-session rule text flips at the phase that makes it true.openItems P0 → auth rule flip P4
allowed-depsAdditions (server-side Better Auth, JWKS lib, a Node runtime for the auth service, and any Fly-deploy tooling) added one-at-a-time at build time per the controlled-allowlist rule. Deletions of @neondatabase/serverless + drizzle-orm from apps/web happen at cutover (P4) — the live edge auth still uses them; removing earlier would red-gate the running deploy.additions P1/P2; deletions P4
repo-processFly deploy convention (flyctl via sops exec-env), CF Pages = static-only.openItem P0 → lock at P1

Three gaps explicitly folded in (per Jon id=313)

  1. Deletions are amendments too — the edge-driver allowlist removals above are a scheduled P4-cutover task, not pre-emptive.
  2. OAuth callback re-registration — Google + GitHub redirect URLs and BETTER_AUTH_URL must be re-pointed from the edge origin to the Fly auth origin at P4 cutover; this is an explicit task because it silently breaks the "a real user can OAuth" acceptance bar otherwise.
  3. P0.5 spike is written into this ADR as P1's entry condition (above).

Consequences

Easier: reuse the existing migrated Python backend with no translation cost; FastAPI keeps OpenAPI authority and emits typed TypeScript clients to the frontend; a real persistent backend (no edge-only constraints); clean cross-repo sharing through the four seams; single source of DDL truth; the shared chat-UI package stays TypeScript on the frontend seam.

Harder / to revisit: the proven edge auth slice must be re-platformed to a Node Better Auth service on Fly (a migration, not a throwaway); the Node Better Auth + Python FastAPI seam must be proven on Fly in P0.5; a Fly app is one more deploy/observability surface (bounded — two co-located processes + a CDN); BA schema changes require a small hand-sync into Alembic (decision A); the backend is Python while the frontend + chat-UI are TypeScript, bridged by the OpenAPI-to-TypeScript-client seam.

Action items (phase sequence & lanes)

Lanes (Jon id=313): cc2 = ADR + Fly foundation + auth re-platform · claude-1 = Letta memory-service extraction · codex = governance-doc + gate/review. One owner on auth, no duplication.

  1. P0 — this ADR + safe contract amendments (data reaffirmed; backend-api/repo-process openItems staged). (cc2)
  2. P0.5 — Fly go/no-go spike — GO (2026-06-01): Node BA + FastAPI co-located on Fly, Fly→Neon egress, localhost JWT/JWKS validated, SOPS-injected secrets. (cc2)
  3. P1 — Fly foundation: provision app, Node Better Auth service + Neon via SOPS, JWKS endpoint. (cc2)
  4. P2 — Python/FastAPI domain API + Docling/doc-AI co-located on Fly; JWT/JWKS validation seam. (cc2)
  5. P3 — CF Pages = static Vite shell only. (cc2)
  6. P4 — cutover edge→Fly: migrate live auth, flip the backend-api auth-session hosting rule, allowlist deletions, OAuth callback re-registration; dev-smoke the auth acceptance contract to parity with the proven edge slice. (cc2)
  7. P5 — re-lock the stale Track-2 wizard plan to this topology, encode, build. (cc2 lead)
  8. Shared seams: extracted-Letta memory service (claude-1); chat-UI package + MCP tool contract — sequenced after the foundation.

Nothing in P1+ is enforced or built before Jon's explicit yes at each gate.

On this page