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:
- 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.
- 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):
- 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. - 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-systembackend — 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. - Host = Fly.io. Was provisional pending a P0.5 go/no-go spike — RESULT 2026-06-01: GO, Fly CONFIRMED (see below). The spike decided, not preference.
- 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
| Option | Tools | Trade-off |
|---|---|---|
| A — Alembic sole-DDL (chosen) | one migrator | single 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.* schema | two migrators | removes 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
| Option | Assessment |
|---|---|
| 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 rewrite | would translate a working Python backend for no benefit, and duplicate what the OpenAPI-to-TypeScript-client seam already provides — rejected |
Host
| Option | Assessment |
|---|---|
| Fly.io (chosen, provisional) | runs Node + Python persistently against Neon; SOPS-delegated token verified live |
| Cloudflare edge only | cannot run FastAPI; rejected as an edge-only shortcut |
| Railway / Render / VM | viable; 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:
- A minimal Node Better Auth service boots, reaches Neon over the Fly network, and serves
/api/auth/*+ a JWKS endpoint. - A minimal FastAPI process co-located in the same Fly app validates a Better Auth JWT via JWKS over localhost.
- 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:
| Contract | Amendment | Phase |
|---|---|---|
data | No change — migration-authority already encodes A. Reaffirmed. | P0 (done) |
backend-api | auth-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-deps | Additions (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-process | Fly 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)
- Deletions are amendments too — the edge-driver allowlist removals above are a scheduled P4-cutover task, not pre-emptive.
- OAuth callback re-registration — Google + GitHub redirect URLs and
BETTER_AUTH_URLmust 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. - 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.
- P0 — this ADR + safe contract amendments (data reaffirmed; backend-api/repo-process openItems staged). (cc2)
- 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)
- P1 — Fly foundation: provision app, Node Better Auth service + Neon via SOPS, JWKS endpoint. (cc2)
- P2 — Python/FastAPI domain API + Docling/doc-AI co-located on Fly; JWT/JWKS validation seam. (cc2)
- P3 — CF Pages = static Vite shell only. (cc2)
- P4 — cutover edge→Fly: migrate live auth, flip the
backend-apiauth-session hosting rule, allowlist deletions, OAuth callback re-registration; dev-smoke the auth acceptance contract to parity with the proven edge slice. (cc2) - P5 — re-lock the stale Track-2 wizard plan to this topology, encode, build. (cc2 lead)
- 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.
Decisions (living ledger)
Confirmed decisions, target workflow, and open questions for the new Cloudflare-hosted blockdata repo. Maintained by Claude.
Cloudflare stack proposal
Proposed replacement map for moving the clean repo away from Supabase and toward Cloudflare-first dev and production infrastructure.