Design decision · CI/CD architecture

Designing the CI/CD — intentional deployment behind staged quality gates

Automated agents open pull requests and run the gates; a staged gate ladder decides what merges; deployment stays a deliberate, health-gated promotion — and a human authorises anything irreversible. The goal: ship consistently at high reliability, where only intentional changes deploy after a thorough quality check. This is the iteration journey: three full options weighed, industry best practice, the test-depth ladder, and the recommended best-fit.

The one seam — merge ≠ deploy

Automated agents open many PRs from worktrees; the gate ladder decides what merges. Speed (auto-merge the flood behind strong gates) and control (only intentional changes go live) resolve on one seam — and every irreversible act stays human-authorised.
Auto-merge → stage
fast, agent-driven, behind strong gates. CI green IS the standing approval. stage never deploys.
Promote → main → deploy
deliberate + decoupled (Coolify webhook, per-service flag). You promote stage→main one service at a time; a health gate confirms.

The gates buy the trust to auto-merge — into stage, where merging changes the code of record and ships nothing. Quality buys speed; deploy stays intentional, on main. ↓ the full model next.

The branch model — stage integrates, main promotes (per-service gated deploy)

The design intent: decouple live deploy from the delivery pipeline, so deploy stops being a bottleneck. The fix: one branch the pipeline merges into (stage, gated by CI only, never deploys) and one branch that ships (main, promotion-only). Keep merging work all day; deploy when you decide, one service at a time.
feat/product-a-work feat/model-gw-gate feat/<service> · … PR · CI gate ✓ lint + DTO-drift ✓ unit + coverage ✓ contract test ✓ docker boot + health green ⇒ merge OK stage · integration all PRs land here CI green = merge · merge as you go locally dev-test the whole line ▣ NEVER deploys main · production receives ONLY promotions + the live-state refresh (build-log · status · state) deploy = run deploy-all promote (Sam · per service) merge site / docs → DEPLOYS your interface stays live DEPLOY_SITE_ENABLED = true (landing · build-log · status) service → deploy GATED off until you flip it, per service DEPLOY_<SVC>_ENABLED = true + secret COOLIFY_HOOK_<SVC> promote to main, then run deploy-all — gated per app hourly state-refresh cron site/docs/state → main (live)

Read it left→right: every feature branch opens a PR into stage; the CI gate is the only thing that decides the merge; stage accumulates the whole line and never deploys. When a service is ready, you promote it to main and flip that one service's deploy flag — site/docs deploy freely (your interface stays live), services deploy only when their DEPLOY_<SVC>_ENABLED var + COOLIFY_HOOK_<SVC> secret are set. The pipeline never blocks on deploy again.

The promotion ritual — stage → main, one service at a time

1
Merge to stage all day — every PR targets stage; green CI is the gate. No deploy fires, ever. the pipeline runs at full speed
2
Dev-test the line — pull stage locally / a preview, prove the change end-to-end (build+run+curl+docker). this is where "works on stage" is earned
3
Flip the gate for that service — set repo vars.DEPLOY_<SVC>_ENABLED=true + secret COOLIFY_HOOK_<SVC> (once per service). arms exactly one app
4
Promote — merge stage → main. On push, each service's deploy job fires its Coolify webhook only if its flag is on + its paths changed. one push, only the armed + changed apps redeploy
5
Re-freeze — leave the others' flags off; a frozen service can't be disturbed by an unrelated promotion. frozen-once-working stays mechanical

One-time console setup — what actually unblocks the decouple

Before the gated-deploy workflow reaches main via the first promotion, the immediate decouple is purely Coolify-console — no merge needed:
Turn Coolify git-auto-deploy OFF on the service apps (Identity service · Product services · Model gateway). stops "every push to main redeploys" — the bottleneck — instantly
Leave auto-deploy ON for the site app (or set DEPLOY_SITE_ENABLED=true once #45 lands). your build-log/interface keeps updating live
When ready to ship a service: add its COOLIFY_HOOK_<SVC> deploy-webhook secret + set DEPLOY_<SVC>_ENABLED=true, then promote. per-service, reversible, intentional

Stage as a deployed environment

The model of record for what "stage" now is. The branch model above kept stage a CI-only line ("stage NEVER deploys"). This is the evolution: stage becomes a deployed integration environment — every new or changed service is proven on a live stage first, then promoted to prod. This section defines how, so a service is never wired into production by accident.

The design decision — isolation must be structural, not a branch flip

By design, the platform starts on one Docker network with the prod domains — the minimal footprint for a solo operator, chosen deliberately. Adding a stage environment therefore carries an explicit, documented decision: isolation must be structural, because a service "deployed to stage" on the prod network would otherwise be reachable by prod, and vice-versa. Concretely, the rejected shortcut is pointing the existing prod gateway app at the stage branch — that does not create a stage environment; it makes the prod Model gateway (the one live product clients call) run stage code on the prod domain and prod network, conflating the two. The intended end-state below gives stage its own network and domains, so the two environments never touch — a stage that shares prod's network and domains would not be a stage at all.

The trap: "just switch the prod gateway to the stage branch to test." That doesn't add an environment — it replaces prod with stage code, on the domain live products depend on. Isolation has to be structural (a separate network + separate domains), not a branch flip on a prod app.

The design — isolated, cheap for a solo builder

Two parallel worlds on one VPS: prod and stage never touch. Each pillar is one concrete, cheap-for-solo primitive.
PillarProdStage
Network
no cross-talk
the internal networkan isolated stage network — docker network create, once
App set + branch
prod apps NEVER repointed at stage
Coolify apps watch main, on the internal networkseparate Coolify apps watch stage, on an isolated stage network
Domains
wildcard already → VPS; auto-TLS
the prod service domaina stage.-prefixed domain — internal-only services (the model runtime) need none
Container names
so they resolve each other, no collision
Model gateway · model runtimesuffixed -stage: gateway-stage · runtime-stage
Databasesprod managed-Postgres DBa copy-on-write branch of prod — instant, cheap, isolated. Stateless services (gateway, runtime) get none
ONE VPS · Coolify / Traefik · wildcard domains → VPS (auto-TLS) PROD the internal network · watches branch main · frozen Identity serviceauth · billing · email Model gatewayprovider switch Model runtimeinternal · no domain (after promote) Product servicescall Identity /me+billing · Model /text managed Postgres — prodper-service DBs · live data ✕ ISOLATED — no cross-network reach STAGE an isolated stage network · watches branch stage Model runtime (stage)internal · no domain Model gateway (stage)stage domain · runtime URL → stage runtime …only the service(s) under test — the rest of prod is never mirrored (see "lighter alternative" below). managed Postgres — branch of prodcopy-on-write · isolated · stateless svcs get none promote: stage→main + flip DEPLOY_<SVC>_ENABLED → prod app (on main + internal net) deploys

Two worlds, one VPS: prod apps watch main on the internal network and are never repointed at stage; stage apps watch stage on an isolated stage network, get stage.-prefixed domains, and resolve each other by their -stage names. The dashed wall is the whole point — no container on one network can reach the other. Promote-to-prod is the existing gated-deploy model with a stage rehearsal in front: verify on stage → promote stage→main for that one service + flip its DEPLOY_<SVC>_ENABLED gate → the prod app deploys.

A lighter alternative (cost-aware — the recommendation for a solo builder). You do not need a permanent, full stage twin of everything. For a single service under test, stand up an ephemeral stage app (stage branch, isolated stage network), verify, promote, then remove it. Prod (frozen Identity service + Model gateway) is never touched. Run a full parallel stage only when you genuinely need it; until then, ephemeral-per-service is cheaper and just as isolated.

Runtime-first walkthrough — the concrete first case

The first service to earn a stage: bring a self-hosted model runtime online behind the Model gateway, proven on stage, then promoted.
1
Stand up the stage runtime — the model-runtime service, stage branch, on the isolated stage network. Internal only, no domain. the model runtime
2
Stand up the stage gateway — the Model gateway, stage branch, on the isolated stage network, its runtime URL pointed at the stage runtime → a stage domain. the gateway, wired to the stage runtime
3
Verifycurl the stage gateway /api/text/health"runtime":"ok"; then a real /api/text/complete with {"provider":"self-hosted"}. the round-trip on real infra
4
Point a product client's e2e at the stage gateway — exercise product → gateway → runtime end-to-end. nothing prod is touched
5
Promote — deploy the runtime to the prod internal network + point the prod Model gateway's runtime URL at it → the prod gateway shows runtime:ok. same gated promotion, now proven first
Two configuration invariants this enforces. (a) The runtime URL belongs on the Model gateway, not the Identity service — the Identity service never calls the runtime, so setting it there is a no-op (a runtime:skipped reading is the signal it is misplaced). (b) Prod stays on the main branch with a separate stage gateway standing up for stage work — the prod gateway is never repointed to run stage code. Both are documented guardrails that keep the two environments distinct by construction, not one-off fixes.

Stage stops being a promise the CI makes and becomes a place you can curl — isolated by its own network and its own domains, so proving a service never risks the ones a stranger is paying for.

The deploy sequence — dependency-ordered, one button (CD ≠ CI)

The design intent: separate CI and CD — one deploy GitHub Action deploys them all from main to live, so dependency order is respected. CI is the gate (runs on every PR into stage); CD is this — one manual, dependency-ordered workflow (deploy-all.yml) that fires the per-service Coolify webhooks tier-by-tier, health-gated. Template lifted from a release-workflow pattern (release.yml): workflow_dispatch (never auto), per-job if: vars.<FLAG>, needs: for order.
▶ workflow_dispatch (manual) · from main only · each job armed by vars.DEPLOY_<SVC>_ENABLED T0 · foundation · no internal deps · parallel Identity serviceauth · billing · emailown DB · payments · email Model gatewayprovider switchstateless · LLM providers ⛒ health gate — Identity (health + db + payments) ok · Model (default provider) ok T1 · products · call Identity /me+billing+email & Model /text Product Aguest checkout Product Btext product Product Ctext product ⛒ health gate — each product /health ok T2 · edge · static site / landingstatic · independent

One press deploys everything armed, in the right order: Identity service + Model gateway first (the foundation a product would 404/500 against if it weren't up), each health-gated before the product tier starts; products next (they call the Identity service /me+billing+email and the Model gateway /text); site last. A tier proceeds when its deps succeeded or were skipped (flag off) — never when one failed, so a half-broken foundation can't drag a product live. Scaffolded as .github/workflows/deploy-all.yml.

CI proves the code; CD ships it — deliberately, in dependency order, one button, one service's gate at a time.

Failure modes & recovery — what the gates don't catch

Automated gates are necessary, not sufficient. This is the honest account of how the system degrades and how it recovers — the operational backstop behind the green ladder, and where human judgement stays in the loop.
Failure modeWhat happensRecovery
Health probe fails post-deployA tier's /health · /health/{db,payments} returns non-200 or degraded after its Coolify webhook fires.The tier's promotion halts and the service rolls back to the previous good image (kept by tag). Because tiers are health-gated in dependency order, the product tier never starts against a dead foundation — products can't land on a broken spine.
Rollback itself failsThe previous image will not come healthy — a data-shape or infra fault below the app layer.An alert fires and the run stops at a documented break-glass manual revert (redeploy a known-good tag by hand, or take the app behind a maintenance page). No further tier promotes until a person clears it.
A semantic bug passes every automated gateLint · tests · contract · docker-boot · e2e are all green, but the change is wrong in intent — plausible-looking, incorrect behaviour.Caught before merge by the adversarial multi-agent review pass on money-path / auth changes, and after by a postmortem loop that turns each escape into a new regression test. Human judgement is the backstop — the automated gates are the floor, not the ceiling.
Where the automated gates stop — stated honestly. The contract (Schemathesis) and health probes verify schema conformance and liveness — that an endpoint answers, and answers in-shape. They do not verify business intent: a route can be perfectly schema-valid and still do the wrong thing. That limit is exactly why deployment stays a deliberate, human-authorised promotion and why irreversible acts (deploy, money, sends) always wait on a person. Observability plus the postmortem loop close the gap the gates can't — automated gates are necessary, human judgement is the governance backstop.

Auto-merge to stage + automated remote testing — the verification rungs

Two upgrades that let the pipeline run the repetitive path once stage is in place — auto-merge the integration line behind green gates, then verify against a real remote environment before anything is promoted to production.

Auto-merge to stage on green CI

PRs into stage squash-merge themselves the moment their CI is green — safe because stage never deploys; auto-merge only advances the integration line. Mechanism: .github/workflows/automerge-stage.yml — an Action that fires when a CI workflow completes, finds the matching green PR targeting stage, and squash-merges it (skips drafts + hold/wip/do-not-merge labels, and waits until every check is green).

⚠️ Constraint surfaced (a Sam decision): GitHub's native auto-merge + branch protection (required status checks) need GitHub Pro or a public repo — they 403 on a free private repo. So we use the Action path, which works on a free private repo via the workflow GITHUB_TOKEN. (workflow_run Actions run from the default branch, so this file ships to main with the CI/CD infra and governs PRs into stage.) If you'd rather have native auto-merge + protected branches, that's the one upgrade that unlocks it.

Automated remote testing on the stage environment — smoke + e2e (next)

The design intent: once the stage environment exists, enable autonomous testing on a remote dev-test target — smoke + e2e. Today verification is local + mocked (the dev-test stacks). The next rung stands up a stage/preview deploy of the line, then runs against its live URLs, automatically, before promotion:

A
Smoke — per service, post-deploy: /health green + one real round-trip (Identity service passwordless sign-in {sent:true} · Model /text · a product call). proves the wiring on real infra, not a mock
B
E2E — the money path end-to-end on stage: passwordless sign-in → checkout → webhook flips plan → product delivers. the thing a stranger actually does
C
Gate promotion on green — stage smoke+e2e green becomes the precondition for stage→main. "works on stage" is then machine-verified, not asserted

This turns the dashed rungs 4–5 of the pipeline (drawn above) real: verification moves from mocked-local to real-remote and automated, and nothing is promoted to production that hasn't proven itself on a live stage first — promotion of the money spine still waits on a human.

The gate ladder — the "thorough quality check"

Every gate a PR into stage clears, with its cost and what it catches. Cost-ordered, fail-fast; the green ladder is what lets agents auto-merge to stage unattended.
0
Lint + DTO-drift — ruff · regen DTOs from openapi.yaml & git diff --exit-code. ~5s · style drift + code/spec disagreement
1
Unit + coverage floorpytest --cov --cov-branch --cov-fail-under (spine 90 / products 80) + diff-coverage (the patch must meet the floor — kills "ship a feature, skip its tests"). 10–60s
2
Contract — Schemathesis fuzz (not_a_server_error + response_schema_conformance). Extend the Identity service's gate to the products + Model gateway. 30–90s · no endpoint can 500 or lie
3
Integration — docker compose boot → migrate → host-liveness + container-healthy (catches the IPv4/IPv6 bind class behind the Identity service's old 502). 1–3 min · path-scoped
4
E2E — boot the real chain + stubs, assert the round-trip. Promote the text-ops dev-test script to a CI job; add money-path + guest-fulfil. Stub-backed = deterministic + secret-free. 2–5 min · nightly + pre-promote, NOT a required check
5
Deploy-probe — after deliberate Coolify promotion, curl live /api/health + /health/{db,payments}. ~30s · post-deploy · turns "green CI" into "green prod"

Rungs 0–4 are pre-merge (the trust); rung 5 is post-deploy and decoupled. Anti-coverage-theater: per-service floors (not a repo average that lets the spine hide an untested product), branch coverage, diff-coverage as the real teeth, and the omit/pragma config itself reviewed on every PR (the "ignore the auth tests" failure mode).

Reality check — the core subset vs the fuller map

Most of this is a map, not a march. The honest core-vs-fuller slice, drawn.

This page describes the delivery model in full; deliberately, it is grown to fit real load rather than gold-plated ahead of it — so a focused core subset carries the weight and the rest is staged until concurrency and traffic justify it. Here is the core set of CI workflows the design defines under .github/workflows/:

spine CI · core/**
test: DTO-drift · ruff · pg · migrate+pytest+cov · Schemathesis
docker-smoke: boot → curl /api/health · inspect healthy
services CI · services/**
ruff lint
test matrix [products · Model gateway] · pytest
site · site/**
design-audit.sh · tokens · links · nav
automation · default-branch
automerge-stage.yml · green → squash
deploy-all.yml · manual, ordered CD

✓ core of the design: the identity spine plus its full gate set · the path-filtered CI (spine · services ruff+matrix · the site design gate) · automerge-stage.yml + the one-button deploy-all.yml (activated from the default branch, since workflow_run/CD fire there). ○ fuller map: a merge queue, CODEOWNERS, services.yml docker-smoke rigor, and remote-stage smoke+e2e.

Core vs fuller map. Core of the design (on the stage line — activated once promoted to main, since workflow_run/CD fire from the default branch): the stage→main branch model (deploy-gate vars defaulted site-ON/services-OFF), auto-merge to stage on green (automerge-stage.yml), and the one-button dependency-ordered CD (deploy-all.yml + per-service DEPLOY_<SVC>_ENABLED gates, lifted from a release-workflow pattern). Fuller map: a merge queue, CODEOWNERS, services.yml docker-smoke parity with the spine, and the e2e harness promoted to a real CI job on a remote stage env. Read "queue / preview / rollback / remote-e2e" as the fuller map; "auto-merge / gated deploy / branch model" as the core.
The minimal subset (the MVP pipeline): the branch model on stage (merge freely, no deploy) · site/ stays live on main via the hourly state-refresh · keep the products and the spine green · add a CODEOWNERS gating core/ (the last cheap pre-revenue gate). The immediate console step is the one true unblock: Coolify git-auto-deploy OFF on the service apps, ON for site.

How we got here — options & best practice, condensed

Three options weighed in full by independent agents, then checked against 2026 practice for our exact shape (small monorepo · Coolify-decoupled · many-agent PR flood).
Option exploredVerdict
1 · Incremental hardening S
close the gaps on today's 3 workflows (cache · services rigor · auto-merge · probe)
The right move now (≤4 services); copy-paste won't scale past ~4, no cross-service gate.
2 · One reusable workflow_call pipeline, matrix-called M
the reusable-workflow pattern, + a ci-gate aggregator
DRY — tighten a gate once, all inherit; add a service = one line. Adopt when a 3rd/4th service makes copy-paste hurt.
3 · Merge queue + affected-only M
re-test each PR vs main-as-it-will-be before landing
Eliminates the stale-but-green-breaks-main failure; overkill until the agent flood is genuinely concurrent.

Explored: incremental → reusable-matrix → merge-queue. Not either/or — they're the sequenced best-fit below, adopted in that order as scale demands.

2026 best practice — the cited verdict, 5 bullets

The recommended design — sequenced best-fit

The concrete target after the reality-check. Each step independently valuable; adopt in order.
WhenDo (concrete)Why this slice
Now S · pre-revenue-safecache: pip + existing concurrency · a CODEOWNERS gating core/ (the enabler — makes Tier-A promotion real) · auto-merge into stage (automerge-stage.yml) · keep the products greencheap, safety-first — the ONLY CI work that earns its keep before a stranger pays
Soon post-first-dollar · Mthe gatekeeper-job as the one required check + native auto-merge (Tier B) · a contract gate on the products · path-scoped docker-smoke · the reusable service-ci.yml matrixwhen a 3rd/4th service makes copy-paste hurt & auto-merge throughput matters
Later when-concurrent · Me2e-in-CI (promote the harness) · coverage floors + diff-coverage · the GitHub merge queue + flaky-quarantine · the Model gateway matrix leg~0 value until agents truly collide on main — sooner is infra-before-income
Merge policy under the stage model. The tiering moved from "what auto-merges" to "what promotes" — because auto-merge now targets stage, which never deploys, so merging is consequence-free. Into stage: all PRs auto-merge on green CI — green is the standing approval (automerge-stage.yml; skips hold/wip/do-not-merge). Promotion stage→main (the actual deploy decision) is where "never merge without permission" lives: Tier A — money spine & control plane (core/** · .github/** · deploy/secret config · *.env*) needs explicit per-service human approval + a flipped DEPLOY_<SVC>_ENABLED gate; Tier B — low-risk surfaces (site/** · thin off-money-path services) flows to main freely (the live-site/-push grant). CODEOWNERS remains the Tier-A enabler (still to add).
What full automated operation still needs beyond the gates: auto-rollback/revert on a red probe (see the maturity roadmap), real-time alerting (today only a daily health cron — up to 24h blind), a secret-scan gate (gitleaks — agents commit unattended), cost ceilings (CI-minutes + token-budget circuit-breaker), a break-glass override, and written branch-protection specifics. These are the observability + governance layer around the gates — not optional polish.

Deploy & freeze — the intentional, dependency-ordered ship

What makes "merge ≠ deploy" real. Merging only ever advances stage; shipping is a separate, deliberate act — the one-button, dependency-ordered deploy-all.yml drawn in The deploy sequence above. Coolify git-auto-deploy is OFF for every service app, so that manual workflow is the only thing that ships a service (each job armed by vars.DEPLOY_<SVC>_ENABLED + secret COOLIFY_HOOK_<SVC>, ordered by needs: + a health gate); site/ alone stays live on main via the hourly state-refresh.

The freeze cycle — frozen by default, promoted deliberately, re-frozen

A frozen service (the Identity service, the Model gateway) leaves its DEPLOY_<SVC>_ENABLED var unset, so deploy-all.yml simply skips its job — it never ships, no matter what promotes to main. An intentional promotion is a three-step loop: flip the var on → run deploy-all → flip off.
1 · Frozen (default)
DEPLOY_<SVC>_ENABLED unset → its deploy-all job is skipped → the service never ships.
2 · Flip ON → deploy
Set the var =='true' for this one promotion; run deploy-all.yml from main — its job fires the Coolify webhook, health-gated.
3 · Flip OFF → re-frozen
Unset the var; verify health + record the new frozen commit. Back to step 1.

So merge moves stage; promotion moves main; deploy is a separate, manual, dependency-ordered, per-service-gated act — armed by a var, fired by a secret, ordered by needs:. Automated agents can flood stage with green PRs all night and nothing reaches prod until a deliberate deploy-all run says it may.

The controlled-change protocol — extending a frozen service

"Frozen" means never touched unintentionally — NOT never changed. The Identity service + Model gateway still gain intentional, additive extensions (e.g. the Identity service's guest-checkout for a product). Every such change follows one protocol so a frozen service stays trustworthy.
  1. Additive-only, on a feat/ branch into stage. A new endpoint / provider / field / migration — never a rewrite of the existing contract. Existing consumers must be unaffected. (The Identity service is OpenAPI-first → the DTO-drift + Schemathesis gates prove backward-compatibility; the Model gateway adds providers as one entry in _PROVIDERS, call sites unchanged.)
  2. Full gate ladder green (the same ladder above): lint · tests · contract/Schemathesis · docker boot+health. For a money-path or auth change, an adversarial multi-agent review (a documented pre-merge verification pass) before merge.
  3. Intentional, flagged deploy. Promote stage → main (merge ≠ deploy). To ship: flip that service's DEPLOY_<SVC>_ENABLEDtrue for the one deploy, run deploy-all.yml, then flip it back OFF → re-frozen. Never a standing auto-deploy on a frozen service.
  4. Re-verify live + record the new frozen commit. Health green, the new surface works, existing surface unchanged. The service is frozen again at the new commit.

Planned extensions (the only sanctioned changes to the frozen set)

ServiceExtensionWhy / when
Identity serviceGuest-checkout endpoint pair (guest-checkout-session + guest-verify-session) — additiveUnblocks a product's no-login checkout. Existing authed routes untouched; tests + contract green on the branch.
Identity serviceParallel live payments webhookConsole step; the additive money-path piece for live charges.
Model gatewayNew providers via _PROVIDERS (e.g. another text model, or a vision model if a product needs it)One additive entry each; call sites never change. Only when a product actually needs it — not speculatively.
BothDependency / security patch bumpsSame protocol — gated, intentional redeploy, re-freeze. Never an ad-hoc push.

The everyday state of a frozen-infrastructure service is exactly that — frozen. Changing one is a deliberate, planned event, never an ad-hoc push: it is proposed, then executed by this protocol, then re-frozen.

The maturity roadmap — automated recovery under human oversight

6–12 months out: the delivery system handles the repetitive path and recovers from common failures automatically, while a human stays on every irreversible decision. Every node is a real GitHub / Coolify / managed-Postgres capability — no magic.

The full PR → prod loop — automated path, human-gated deploy

open
agent PR · auto-merge armed
gate
ci-gate · rungs 0–4
land
merge queue · vs future-main
preview
Coolify pr-NNN · Postgres branch
promote
probe green → promote · red → rollback

The agent arms gh pr merge --auto on open (the ladder is the approval) → the merge queue rebuilds each candidate on main-as-it-will-be so two green agent PRs can't break main together → Coolify spins a preview at a per-PR subdomain against a fresh copy-on-write Postgres branch (destructive money-path tests on real-shaped data that vanishes on PR close) → the deploy-probe curls /health/{db,payments} + the passwordless-sign-in→checkout→webhook→plan-flip smoke; green clears the image for promotion — and the money spine still waits on a human to authorise the deploy. Mechanical where it's safe, human-gated where it's irreversible.

Automated recovery — what notices at 3am, rolls back, and hands a human the incident

Probe fails
rung-5 returns non-200 / degraded within 60s of promotion
Auto-rollback
Coolify redeploys the previous good image (kept by tag); healthy in ~30s
Auto-revert main
gh opens a revert PR of the bad merge; it rides the same queue out
Tracking issue
a labelled issue with probe output + logs; a Sweeper agent picks it up

Detection is cheap; automated recovery is the goal — main reverted, prod rolled back, and a fix-it incident issue waiting for a human, before anyone woke up.

The 80/20 line — build this first. ~80% of the automated-recovery value is already the "Now" + "Soon" columns: the gatekeeper job + native auto-merge + the rung-5 probe + Coolify rollback-to-previous-image + CODEOWNERS tiering. That gives unattended, self-recovering landing for site/ + thin services, with core/ human-gated. The merge queue, Postgres-branch previews, the agent work-queue, and cost accounting are the genuinely-further-out 20% — build them only as concurrency and product count make them pay.

The dev-test stacks & test doubles — documented

Every container in docker ps during a dev-test, named and labelled (so a stub is never mistaken for a real service). The mock-provider target is the house rule: fake at the seam, never our own services.
ContainerWhat it isReal / test-only
Model gatewayThe real model-call gateway (Flask). Fake the model at the seam: run LLM_PROVIDER=mock (a contract-conforming mock provider in _PROVIDERS) — in-process, no stub container.REAL service
Product clientA reference / e2e fixture, NOT a product — the minimal real consumer of the gateway + the canonical house-pattern example. The real text products are separate Product services.REAL — reference/fixture
stub-runtimeAn ad-hoc fake-HTTP model server — the wrong layer. Replace with LLM_PROVIDER=mock (fake at the provider contract, not a stub container).RETIRE → mock provider
stub-identityFaked our OWN service — don't fake what we own. Replace with a REAL Identity service: the live deployment or a forked managed-Postgres test branch with test data.RETIRE → real/forked spine
Faking strategy: fake ONLY external deps, at the provider-contract seam — not as stub HTTP servers; run our OWN services real. The model is a spectrum via one env var: LLM_PROVIDER=mock for CI (deterministic, no key) · a self-hosted runtime / an LLM provider for rich local testing · an LLM provider in prod. The Identity service is never faked. Stubs hold no state, carry no keys, never deploy. (skill: fake-external-deps-at-the-seam)