Skip to content
GitHub
Decisions

ADR-109: Cloudflare hosting topology — one app container, predicate in-process, Supabase locked

Context

Spectral is I/O-bound and network-waiting end to end. The only heavy compute is LLM inference, which runs at the provider and only on the authoring path (the World Agent) — never on the /api/decide hot path, which executes tiny generated predicates in the ADR-083 sandbox. Nothing is CPU- or GPU-bound. The data substrate — Supabase (Postgres, Auth, pgvector, Realtime) plus the ADR-044 transactional outbox — is locked.

This ADR settles where the API/workers compute runs and in what shape. The human-facing Vite/Astro surfaces (app., ops., docs., codex.) deploy as Cloudflare Pages projects per ADR-052.

Decision

D1 — One Cloudflare app container, both Python entrypoints

Spectral’s runtime is a single Cloudflare Container built from infra/docker/app.Dockerfile and launched by app_supervisor.py, which runs both entrypoints as sibling processes: the FastAPI API (port 8000) and the background workers runtime. A thin Cloudflare Worker fronts the container and proxies every request to the API port. Supabase stays locked.

The API and the workers stay separately-launchable entrypoints (apps/api and apps/workers are distinct images/entrypoints), so splitting a tier onto its own host later is a deploy-manifest change, not a code change.

D2 — Predicate execution in-process (alpha)

The decision/predicate tier — the ADR-083 sandbox executing generated predicates on /api/decide — runs in-process inside the app container for alpha. A separate Workers-hosted predicate tier is deferred: it buys isolation the alpha doesn’t need at this scale, and the sandbox already bounds the blast radius.

D3 — The World Agent runs in the workers entrypoint

The World Agent (one LangGraph definition; operator/batch and customer-chat-with-streaming modes) runs in the workers entrypoint, off the API request path. Its turns are long-running, bursty, and LLM-API-bound — the opposite profile from the synchronous /api/decide path — and its shape is LangGraph-native. The LangGraph checkpointer is Supabase-backed (AsyncPostgresSaver). Operating conditions: bounded runs, resumable chat, observability relayed from container stdout.

D4 — DB connection over the session pooler; the outbox keeps LISTEN

Both the container (runtime) and the deploy’s migration step connect to Supabase over the session pooler (…pooler.supabase.com:5432, IPv4). Session mode supports LISTEN/NOTIFY, so the core.outbox consumer keeps OutboxListener.listen() (with its existing poll fallback); generation stamping and the WHERE generation = $MY_GENERATION claim filter are unchanged. One pooler DSN serves both the container and CI because the Free-plan direct connection is IPv6-only and unreachable from the IPv4 GitHub-hosted runner. (The connectivity spike measured direct psycopg at ~1.6 ms and the session pooler at ~3.8 ms; the container takes the pooler so a single DSN works everywhere.)

D5 — Deploy via GitHub Actions; generation-stamped cutover

Deploys run in GitHub Actions (ADR-110): a fast-forward push of the production branch builds and ships the container via wrangler; main is integration and does not deploy. Cutover is by deployment generation — a new container claims only its own generation’s outbox rows, and the prior generation’s rows simply stop being claimed (no blue-green color flip, no legacy-generation reaper). The container exposes /health (which carries the running version and per-feature wiring) as the deploy go/no-go; graceful shutdown uses the container SIGTERM→SIGKILL window to finish the in-flight turn and commit the outbox cursor.

Schema migrations are forward-only (ADR-032 D4) and expand/contract: a deploy’s schema change must leave the prior generation’s code working against the new schema, so the two generations overlap safely during cutover. This is what makes generation-based cutover (and code-level rollback to the prior generation) safe.

D6 — Supabase locked; DR is Supabase-native

Supabase and the ADR-044 substrate are unchanged. Disaster recovery is Supabase’s native managed backups + PITR (ADR-040) — there is no self-run backup pipeline.

Alternatives considered

  • A two-cloud AWS + Cloudflare hybrid (Lambda/API-Gateway + ECS Fargate, with DynamoDB/SQS/EventBridge for the non-decision tiers). Rejected: it stands up a second cloud plus a second event/checkpointer stack to host an I/O-bound workload that never needed it, duplicating what the locked Supabase substrate (outbox, LISTEN/NOTIFY, PostgresSaver) already provides — operational tax with no offsetting benefit for a solo builder.
  • Two separate Cloudflare containers (one API, one workers). Rejected for alpha: a single combined image is simpler to build, deploy, and keep warm at dogfood scale; the separately-launchable-entrypoints invariant preserves the option to split later without a rewrite.
  • A managed PaaS (Render/Fly/Railway), Cloudflare only at the edge. Rejected: doesn’t collapse to one compute vendor, is less edge-native, and drifts back toward the PaaS shape the pivot left.

Consequences

  • One compute vendor (Cloudflare) + one data vendor (Supabase). No AWS account, no second event system, no separate predicate host.
  • The one-container invariant applies to apps/api + apps/workers; it does not absorb the dashboard, Operations cockpit, or docs sites, which are Cloudflare Pages deployables.
  • The combined image installs spectral[api,workers]; the API imports the worlds ingestion stack at module load, so both extras must be present in the one image.
  • Cutover is generation-based; there is no blue-green color flip and no legacy-generation reaper — old-generation outbox rows age out unclaimed.
  • Supabase-locality latency is best-effort (Cloudflare Containers place at macro-region granularity, not pinned to the Supabase building). Revisit triggers: Supabase-locality latency materially degrades the closed loop; a Cloudflare control-plane outage exceeds tolerance; cost exceeds the modeled envelope at dogfood scale. The mitigation is the same entrypoint seam — re-split the DB-chatty tier onto its own host.