Architecture decision records
Decisions that shape how the platform works, with the context and the alternatives that were rejected. Every ADR has a status line; superseded decisions keep their file and say which ADR replaced them.
Adding one: copy the structure of a recent ADR (context, decision, consequences, what is not included), use the next free number, and list it in the table below (scripts/shapes.py check fails when an ADR file is missing here).
| ADR | Decision |
|---|---|
| ADR-001: Two-channel distribution — Claude Code marketplace + Copier templates | Two-channel distribution — Claude Code marketplace + Copier templates |
| ADR-002: Web testing stack — Vitest + Playwright opt-in | Web testing stack — Vitest + Playwright opt-in |
| ADR-003: Web package manager — pnpm | Web package manager — pnpm |
| ADR-004: Next.js router — App Router only | Next.js router — App Router only |
ADR-005: Node version policy — pin current LTS, bump via copier update |
Node version policy — pin current LTS, bump via copier update |
| ADR-006: gitops-app overlay structure — env-only | gitops-app overlay structure — env-only |
| ADR-007: Cross-repo orchestration — plan-only in v1, execution in v2 | Cross-repo orchestration — plan-only in v1, execution in v2 |
ADR-008: Shape detection — .copier-answers.yml with sniffing fallback |
Shape detection — .copier-answers.yml with sniffing fallback |
ADR-009: service-java stack defaults |
service-java stack defaults |
ADR-010: service-go stack defaults |
service-go stack defaults |
ADR-011: Multi-repo plan format for /app:build-feature |
Multi-repo plan format for /app:build-feature |
| ADR-012: Per-plugin release agent | Per-plugin release agent |
ADR-013: Plugin auto-enable on /shared:new-service |
Plugin auto-enable on /shared:new-service |
| ADR-014: gitops-app composition spec | gitops-app composition spec |
ADR-015: Shape registry as code (shapes.yml) |
Shape registry as code (shapes.yml) |
ADR-016: library-python distribution — git+https via uv |
library-python distribution — git+https via uv |
| ADR-017: The GitOps repo owns every Kubernetes manifest; services ship an image | The GitOps repo owns every Kubernetes manifest; services ship an image |
| ADR-018: Secrets come from External Secrets Operator; git holds references, never values | Secrets come from External Secrets Operator; git holds references, never values (generated values: a credentials Job instead, ADR-072) |
| ADR-019: Supply-chain hardening for generated repos | Supply-chain hardening for generated repos |
| ADR-020: Addons: a stable contract for backing services | Addons: a stable contract for backing services |
| ADR-021: Observability: an OpenTelemetry base, a UI stack as an option | Observability: an OpenTelemetry base, a UI stack as an option |
| ADR-022: Kyverno guard rails, checked before merge and enforced in the cluster | Kyverno guard rails, checked before merge and enforced in the cluster |
| ADR-023: Parallel execution of multi-repo plans | Multi-repo plans run in parallel waves; merging and pinning stay human |
| ADR-024: Spec-driven bootstrap | Spec-driven bootstrap: new repos start with plan-feature, build-feature consumes the plan (superseded in part by 026) |
| ADR-025: Issue triage | Issue triage: /shared:triage classifies, discusses and routes issues; labels are the state |
| ADR-026: Feature specs drive the build | Feature specs drive the build: spec folders, AC ids, tests first, cplat spec |
| ADR-027: dev follows main through pin PRs | dev follows main: service CI asks the gitops-app for an auto-merging pin PR to sha-<7> |
| ADR-028: Unattended delivery without branch protection | Unattended delivery without branch protection: merges wait for green CI in code, /svc:ship, a Stop hook, headless resumes |
| ADR-029: External services in services.yaml | External services in services.yaml: hand-written kustomize packages or Helm charts, wired and pinned by render |
| ADR-030 — Image tags are the full commit sha | Image tags are the full commit sha (sha-<40>); sha-<7> is accepted for one release |
| ADR-031 — Repo-owned companions for skeleton files | Skeleton files stay platform-owned; repo additions go in repo-owned companions (ci-extra, own permissions, own workflows, project notes) that updates never touch |
| ADR-032 — Every run is recorded in a private run store | Every run is recorded in a private run store: one file per run, one commit per flush, buffered locally when unreachable |
| ADR-033 — Failures become auto-filed issues, deduplicated by a signature line | Failures become auto-filed issues, deduplicated by one Signature: <kind>:<16 hex> body line; one module (autoissue) for platform failures, smoke failures and alerts |
| ADR-034 — Fixable dependency findings are patched by the run | Fixable dependency findings are patched by the run: deps recipes per shape, cplat deps patch, 2 rounds, exact revert |
| ADR-035 — Token usage is measured from the local session transcripts | Token usage is measured from the local session transcripts: sessions claimed via CLAUDE_CODE_SESSION_ID, shared calls split evenly, costs estimated from a price table in the repo |
| ADR-036 — The coordinator runs on Sonnet; project and user settings choose otherwise | The coordinator of a headless run uses Sonnet by default; a flag, a project setting (optionally per workflow) or a user setting choose otherwise |
| ADR-037 — cplat commands that bundle steps never widen the session’s permissions | cplat commands that bundle several steps never widen the session’s permissions: each part runs only if the repo allowlist allows it |
| ADR-038 — Alerts reach GitHub through a scheduled poll on the gitops-app’s self-hosted runner | Alerts reach GitHub through a scheduled cplat alerts poll on the gitops-app’s self-hosted runner; rules in app.yaml, lifecycle state in the issues, a dedicated ALERTS_TOKEN for service repos |
| ADR-039 — The weekly retro: a deterministic report in code, agent proposals checked against it | The weekly retro: a deterministic report in code, agent proposals posted only when their evidence is in the report; a public issue with counts, the detail in the private run store |
| ADR-040 — Smoke tests run against a deployed environment through one recipe and one output contract | Smoke tests run against a deployed environment: a (smoke) criterion marker, devbox run smoke with SMOKE_BASE_URL and one output contract per shape, cplat smoke reads it; red smoke in dev escalates as code, not-run never counts as passed |
| ADR-041 — Staging follows a green dev smoke; prod stays a manual promotion with evidence | Staging follows a green dev smoke through a self-merging pin-staging PR (app-wide setting, per-service override); prod stays a manual /gitops:promote with an evidence summary |
| ADR-042 — A product ship coordinates one repo ship per component, gated per repo, with serialized gitops merges | A product ship (/app:ship) coordinates one unchanged cplat ship per component repo: per-repo gates (smoke, Argo, merged PR) before dependents start, gitops merges serialized in plan order, one product-ship record |
| ADR-043 — One budget per run tree, kept in a local ledger and checked at safe points | One budget per run tree (minutes, agent invocations, CI reruns, estimated cost) in a local ledger keyed by the work, checked at safe points; a stop escalates with cause decision and stays resumable |
| ADR-044 — Change-aware validation in the inner loop only, decided by rules in code | Change-aware validation only in the inner loop, decided by deterministic rules (mandatory categories force everything, unmapped runs everything, risk only adds); the final gate and PR CI stay full |
| ADR-051 — Run state is per worktree | Run state (ship, app-ship, run pointer, recovery snapshot, pending replans) lives in the worktree’s own git dir, so worktrees never block or attach to each other; the gitops merge slot and the evidence cache stay in the common dir |
| ADR-052 — Shared renders and round-robin shards for the platform test suite | Tests that only need a rendered repo share one render per key and process and get private copies; the cplat suite runs in 4 round-robin shards whose coverage a CI job checks; render-subject tests still render |
| ADR-053: A domain model based on Backstage’s system model, with environments | One domain model for the platform: Backstage’s system model (System, Component, API, Resource, Owner) plus Environment, Instance and Resource instance, and the delivery nouns; an internal model in cplat first (4.x), then system.yaml and noun-based commands in 5.0; cluster names never change |
| ADR-054: The 5.0 interface: system.yaml checked by the renderer, noun plugins with tombstones, and a migration that proves itself | The 5.0 interface: system.yaml checked by the renderer, noun plugins with tombstones for every 4.7 command, and a migration that proves the render is unchanged before it merges |
ADR-055: The local console is a stdlib web server over cplat JSON; it acts only through Claude Code |
The local console (cplat ui) is a Python stdlib server on 127.0.0.1 with server-rendered HTML and one dependency-free script; it reads only cplat JSON (subprocesses, snapshots of main), acts only through slash commands run by Claude Code, and delivers notifications locally |
| ADR-056: The platform repo declares its own shape: a template-less registry entry, set only in the committed settings file | The platform repo declares shape: platform in its committed .platform-settings.yml: a template-less, declared-only registry entry that shapes.load() hides from every create, adopt, update and fleet path; never sniffed, no release role |
| ADR-057: Resources are named and typed; each type has a fixed contract; any resource instance may be external | Resources are named and typed (database, bucket, cache, broker); each type has a fixed Secret-key and variable contract, prefixed <NAME>_ when a component uses two of one type; Secret <system>-<name>-app and Argo application addon-<name>-<env> for every resource; any resource instance may be external, synced from the secret store |
| ADR-058: Images and operators are pinned exactly per release; the platform installs the operators a cluster needs as Argo CD applications | Images and operator charts are pinned exactly in the renderer’s versions table per release; the renderer writes the operators a cluster needs as Argo CD applications under operators/ (once per cluster, only when needed, no finalizer); environments.<env>.operators: owner opts out and doctor compares versions (shared clusters: amended by 060) |
| ADR-059: An in-cluster bucket is its own SeaweedFS, run by the platform-installed SeaweedFS operator; its keys are generated in the cluster and Argo CD never deletes it | An in-cluster bucket is its own SeaweedFS (one Seaweed per bucket instance, a storage-sized volume, the filer’s S3 endpoint), run by the SeaweedFS operator that the platform installs under operators/; keys are written once by a credentials Job (ADR-072) and adopted by the operator; Argo CD never deletes a bucket (Delete=false, Retain); versioning: false renders Suspended |
| ADR-060: A cluster repo owns what a cluster shares and lists the systems it runs | A cluster repo (template cluster-gitops, plugin cluster) owns what a cluster shares: the operators it lists (pinned from the renderer’s versions table, linked, never copied), External Secrets with the ClusterSecretStore, the Kyverno engine, the org’s Argo CD repo credential, and one root application per registered system; installing a system is a PR, a check finds missing operators, takeover keeps ADR-058’s application names |
| ADR-061: Databases are backed up by the CloudNativePG Barman Cloud plugin to a bucket resource; a point-in-time restore is a new generation next to the running one | Databases are backed up by the CloudNativePG Barman Cloud plugin (with cert-manager, both platform operators) to a bucket resource; an external target bucket declares remote.s3 in git; a point-in-time restore is a new generation <system>-<name>-g<n> next to the live one, recorded in restore-state.yaml; a switch detaches the connection Secret <system>-<name>-app and repoints it |
| ADR-062: An environment is backed up by Velero, installed once per cluster; persistent volumes by file-system backup; databases, caches and the target bucket are left out | An environment is backed up by Velero (one per cluster, a platform operator with the resources finalizer): per system and environment a location, a schedule and synced credentials in velero; file-system backup of persistent volumes, emptyDir skipped; databases, caches and the target bucket left out by kind and label; restore adds what is missing, --replace pauses and replaces |
| ADR-063: Autonomy is declared in system.yaml; an engine is bound by a contract and only signals can act before a merge | Autonomy is declared per environment in system.yaml (levels, guardrails, pause) and rendered to autonomy.json; an engine such as ai-ops is bound by a contract (issue format, fix run record, audit event, HTTP signals) and only signals (pause, resume, lower) act before a merge |
| ADR-064: A cache is a platform-rendered single-node Valkey StatefulSet, without an operator; its password is generated once in the cluster | A cache is one Valkey StatefulSet the renderer writes itself (official image pinned per major, ClusterIP on 6379, AOF volume only with persistence), with no operator, CRD or chart; maxmemory is 80 % of the container’s memory limit; the password is written once by a credentials Job (ADR-072) into <system>-<name>-app (url, host, port, password); every cache object carries the name label that keeps it out of backups |
| ADR-065: A broker is NATS JetStream from the official chart, with declared streams and consumers and one user per component | A broker is NATS with JetStream from the official chart (a nested Argo CD application per instance, chart and image pinned); streams <system>-<api> and durable consumers named after the component are NACK objects (a platform operator) that renders and syncs never delete; each component has its own user, written once by a credentials Job (ADR-072), <system>-<name>-<component>-app with subject rights and its own inbox prefix; components reach a broker through the events APIs it carries |
| ADR-066: Instances scale with a plain HPA or with KEDA, and the platform installs KEDA per cluster when an instance needs it | scale is an Instance property (component default, instance override, exclusive with replicas); CPU and memory scale with a plain HPA, queue depth (a broker’s durable consumer, a cache list) with KEDA, which the platform installs per cluster as a pinned operator exactly when an environment derives autoscaling: keda; the resource-metrics source is a declared answer of the gitops repo; timings are platform constants |
| ADR-067: Argo CD leaves the replica count of an autoscaled Deployment alone | A scaled Deployment keeps spec.replicas: <min>; the environment’s component ApplicationSet ignores /spec/replicas of each scaled Deployment by name and syncs with RespectIgnoreDifferences=true; autoscaler objects are overlay files that prune when scale goes; KEDA objects sync in wave 1 with retry; no sync option recreates objects |
| ADR-068: Resource and operator upgrades roll through the environments in verified steps | A resource major moves one environment per pull request with /resource:upgrade (dev, then staging once dev is verified, then prod by hand); a database step needs a backup from the last 60 minutes and upgrades in place with its own archive per major; a step counts only when its verification is recorded; platform updates move dev’s pins and leave staging and prod on held pins; operators move once per cluster with dev’s step |
| ADR-069: One versions table pins everything, and every release proves the upgrade from the previous one | Every image and chart comes from one versions table with exact versions and digests, guarded by a pin check; releases list and classify changed rows; each release candidate passes an upgrade end-to-end test from the previous release before it is tagged; External Secrets, Kyverno and LGTM are rendered only for repos that answer property_operators: install |
| ADR-070: The Stop hook holds only the run’s owning session, and only while it makes progress | The Stop hook holds only the session that started or resumed the run (its owner); a run without an owner holds nobody; two owner stops without a new commit or phase change escalate as no progress with a cause chosen by a fixed rule; one module decides for both gates and cplat headless |
| ADR-071: A failing check reaches an agent as a summary made by fixed rules, the full log stays on disk | A failing check reaches an agent as a summary made by fixed rules (per-tool parsers, own-code paths, dedup with counts, fixed limits, redaction); the full log stays on disk with a .summary.json sidecar and agents open it only when the summary is not enough; failure_summary: off or cplat quiet --raw restores the plain tail; spec 102 signatures use the summary |
| ADR-072: A generated credential is written exactly once, by a create-only Job that cannot write it twice | A generated credential is written exactly once by a create-only credentials Job (PreSync hook per owner; create if absent, add-missing-key patch under a resourceVersion test; Role with create and named get/patch, no update), because ESO CreatedOnce can write a generator-backed Secret twice (#316, ESO 2.12.0); an exception to ADR-018 for generated values; amends ADR-059, 064, 065 |
| ADR-073: The platform release is one command that reads its progress from git and GitHub | The platform release is one command (cplat platform release, /platform:release) that keeps no state file and reads its progress from git and GitHub (release branch and PR, squash commit, tag, GitHub release, release-guard run); it stops for the maintainer before the two irreversible steps (`CONFIRM: merge |
| ADR-074: A gate that checked nothing fails | Every covered gate (test, test-fast, validate, render-check, secrets-scan) counts what the tool itself reports and fails on 0 with one line <gate>: checked nothing: <what was empty> - <how to fix>; shell recipes use one byte-identical scripts/gate.sh; the check lives in platform-owned files so /platform:update delivers it; no opt-out; tests/cplat/test_130_empty_gates.py fails for a template or gate without an entry |
| ADR-075: An unattended ship or build runs as one session per unit, each saved at a checkpoint | An unattended /component:ship or /component:build under cplat headless runs as one fresh session per unit (plan, red tests, each task in dependency order, verification, land), one after the other; a checkpoint is detected from the committed feature branch and recorded per worktree in cplat-units.json; the Stop hook ends a unit session at its checkpoint and never escalates it; two sessions of a unit without a commit escalate; 5.5 runs get derived units |
| ADR-076: An unattended run gets its git and gh permissions per session from a checked run allowlist, never from a settings file | An unattended run gets exactly its git and gh actions (feature branch, signed-off commit, push without force, gh pr create, merge only through the land step) per session through --allowedTools, with force pushes, default-branch pushes and other merges in --disallowedTools; a preflight checks every settings layer, the git identity, the gh login and push access before anything is spent; no settings file is ever written; cplat bundles read the grant from CPLAT_RUN_ALLOWLIST |
| ADR-077: Run budgets scale with the plan, and verification gets one grace | Default budgets follow the plan (cost max(15, 10 + 1.5·T + 0.5·C), minutes max(180, 60 + 15·T + 5·C), invocations max(60, 20 + 5·T + C), reruns 6), one ledger part per spec, raised and never lowered when a plan appears; a run with every task done gets one grace of 20 % (at least $3, 30 min, 10 invocations) for verification only; an incomplete transcript read is an unknown cost, never a partial sum |
| ADR-078: Platform changelog entries are fragments the release assembles | A platform changelog entry is one file docs/changelog.d/<id>-<slug>.<added|changed|fixed>.md; cplat platform release assembles them with the [Unreleased] text under the new heading (Added, Changed, Fixed) and deletes them in the release commit; direct [Unreleased] edits keep working; release-check and the version proposal read fragments; tests find a released entry by its version heading and pin plugin versions with >= |
| ADR-079: A PR shows how it was built, generated from the run records | cplat (not the orchestrator) writes a collapsed “How this was built” block into build PRs from the run’s local records (cplat platform runs report --pr <run-id>, appended by cplat pr open): user decisions, hand-backs and fix rounds, accepted deviations, how it ran; only recorded facts, redacted and capped, never blocking the PR; facts no record holds get their own recording call first |
| ADR-080: A pull request runs only the e2e it needs, in parallel parts | On a pull request the cluster e2e runs only when scripts/e2e-scope.py finds a change it exercises (gitops/cluster templates, Dockerfiles, e2e files, the cplat modules the e2e scripts call or import, closed over every import, computed at HEAD) or a release heading; it runs as three parallel parts (E2E_PART core, scaling, backups); nightly runs all in one job; the upgrade e2e decides on the pull request’s own diff |