diff --git a/docs/adr/0100-a-pod-mutator-learns-which-apps-pod-it-is-shaping.md b/docs/adr/0100-a-pod-mutator-learns-which-apps-pod-it-is-shaping.md new file mode 100644 index 00000000..6e421864 --- /dev/null +++ b/docs/adr/0100-a-pod-mutator-learns-which-apps-pod-it-is-shaping.md @@ -0,0 +1,236 @@ +# ADR-0100: A pod mutator learns which app's pod it is shaping + +## Status + +✅ Accepted + +## TL;DR + +An operator can shape every app's pod, or none. It cannot shape one app's pod differently from +another's, because the hook is never told which app it is looking at. + +- **The hook takes a bare pod spec.** `func(*corev1.PodSpec)`. No app name, no namespace, nothing + that says whose pod this is. +- **It is wired once, for the whole process.** So an operator wanting a per-app answer has nowhere to + put the question. +- **Fix: a second spelling of the same hook that is handed the app's identity** — name, namespace, + and whether this is the long-running workload or a one-off run. +- **The old spelling keeps working, untouched.** Same field underneath, so nothing that wired the old + one needs an edit and no pod template changes by a byte. +- **The identity says who, never how to look them up.** No tenant, no environment name, no account. + An embedder keys its own records however it likes; this hook does not learn that shape and cannot + come to depend on it. +- **The platform hook does not move.** Its pods belong to no app. + +Refines [ADR-0061](0061-deploy-pod-mutator-seam.md) §1's signature and leaves its §2 and §3 standing. +Keeps [ADR-0073](0073-placement-policy-reaches-every-authored-pod.md) §2's split by whose image runs, and +[ADR-0077](0077-placement-policy-for-pods-burrow-does-not-author.md) §2's promise that the platform hook keeps its type. +Supersedes nothing. + +## Context + +[ADR-0061](0061-deploy-pod-mutator-seam.md) §1 gave the deploy adapter a seam an operator can use to +shape the pods Burrow authors for apps: + +```go +func (a *Adapter) WithPodMutator(fn func(*corev1.PodSpec)) *Adapter +``` + +The signature was chosen deliberately. §1 says so in as many words: *"Deliberately the same signature +… as the build seam: a reader who understands one understands the other, and there is no second +concept to learn."* That reasoning was sound for what the seam was for. An operator adding a node +selector, a toleration, a sandboxing runtime or a security context applies one policy to every app it +runs, and a hook that is handed only the pod expresses exactly that and nothing more. + +**What is true today.** The hook is registered once against an `Adapter`, and the `Adapter` is built +once per process. Nothing in this repository wires one — `docs/CAPABILITIES.md` states that as a +property, and it is accurate: the only adapter construction outside tests is in `cmd/burrowd/main.go` +and it wires no mutator. The seam exists for an embedder, which is the ADR-0045 bargain +[ADR-0061](0061-deploy-pod-mutator-seam.md) names outright: *"the engine gains an extension point it +does not itself use."* + +**What breaks.** An operator now needs a policy that differs *between* the apps it runs, and the seam +cannot carry one. Concretely: choosing a sandboxing runtime per app rather than per install, so that +one app can be moved onto a stronger isolation boundary while its neighbours stay where they are. The +hook is handed a `*corev1.PodSpec` and asked to decide, and a `*corev1.PodSpec` at that moment does +not say whose pod it is. The pod's labels are not yet a reliable answer at every invocation site, and +reading the container image to work out which app this is would be a re-derivation of a +classification the engine already holds — the exact shape +[ADR-0073](0073-placement-policy-reaches-every-authored-pod.md) §2 rejects, for the reason it gives: *"a wrong branch puts +tenant code on the platform pool."* + +The engine, meanwhile, has the answer in hand. Both places the stored mutator is invoked are inside +functions whose argument already carries the app: `buildDeployment` receives a +`controlplane.WorkloadSpec` and `runJob` receives a `controlplane.RunSpec`, and both types have an +`App` field that is already used a few lines earlier to build labels and the container name. The +adapter's namespace is in scope at both. Nothing needs to be threaded anywhere; the identity is +already there and is being discarded at the last statement. + +**What has to be resolved.** Whether the seam is widened, and if so what it is widened *with* — the +narrowest identity that answers the question, rather than the most convenient object that happens to +be nearby. + +There is one more force worth naming, because it shaped the answer more than anything else. The only +embedder of this seam keys its own per-app records on a tuple this repository has no concept of. It +would be easy, and wrong, to widen the hook with the fields that embedder happens to need today. Then +the public seam would encode one consumer's storage layout, and the next embedder — or the same one +after a schema change — would need the seam changed again. A seam that has to move whenever a +consumer re-keys its database is not an extension point. + +## Decision + +### 1. A second spelling of the seam, carrying the app's identity + +The deploy adapter gains: + +```go +func (a *Adapter) WithAppPodMutator(fn func(PodIdentity, *corev1.PodSpec)) *Adapter +``` + +It is the same seam as [ADR-0061](0061-deploy-pod-mutator-seam.md) §1, told who it is shaping. §2 of +that record is unchanged: the mutator applies on **every** write, so a deploy, a rollback and a config +reapply all produce the same pod template. §3 is unchanged: an adapter with no mutator wired authors +byte-for-byte the pod template it authored before this seam existed. + +### 2. `PodIdentity` says who, and deliberately not how to find them + +```go +type PodIdentity struct { + App string + Namespace string + Workload WorkloadRole +} +``` + +`App` is the application's name, the `App` field of the spec at both invocation sites. `Namespace` is +the namespace the adapter is operating in. `Workload` distinguishes the long-running workload from a +one-off run, which both sites know statically. + +**It carries identity and not context.** No tenant, no environment name, no account, no organisation, +no request metadata. This repository has no concept of any of them, and putting one here would mean +this seam had a view on how an embedder organises its customers. + +This is the constraint that makes the seam durable, so it is stated as part of the decision rather +than left as a note: **`PodIdentity` must never grow a field whose purpose is to be a key into an +embedder's records.** An embedder that needs to resolve `(App, Namespace)` into something it stores +does that resolution on its own side, against its own records, where a schema change costs it a +migration and costs this repository nothing. A seam that hands over a lookup key has quietly taken on +the consumer's storage layout as part of its public API, and every re-keying on that side becomes a +breaking change on this one. + +`Namespace` is a fact about the cluster, which is why it belongs here and a tenant identifier does +not. It happens to be sufficient for an embedder that derives its namespaces from its own records — +but that sufficiency is the embedder's business, not this seam's promise. + +### 3. `WithPodMutator` keeps its exact signature and is retained + +```go +func (a *Adapter) WithPodMutator(fn func(*corev1.PodSpec)) *Adapter +``` + +is unchanged and continues to work. It stores into the **same field**, wrapping the caller's function +so the identity is discarded: + +```go +a.WithAppPodMutator(func(_ PodIdentity, spec *corev1.PodSpec) { fn(spec) }) +``` + +One stored field, one invocation at each site, so there is no precedence question to answer and no +second mechanism to reason about — the last wiring wins, exactly as re-registering the old hook always +has. An operator applying one policy to every app keeps the simpler signature and the shorter +sentence in §1 of [ADR-0061](0061-deploy-pod-mutator-seam.md) stays true for them. + +It is marked `Deprecated:` so tooling and documentation point at the widened spelling, which is a +signpost and not a removal date. Removing it is a separate decision that would need its own record. + +### 4. The platform hook does not move + +`WithPlatformPodMutator` keeps its `func(*corev1.PodSpec)` signature and its reach. +[ADR-0077](0077-placement-policy-for-pods-burrow-does-not-author.md) §2 promises exactly that — *"A third seam, not a widened +second one"* — and this record does not disturb it. + +The promise is worth keeping on its merits and not only because it was made. The pods that hook +reaches are the add-on instance, the log and metrics collectors, and the backup and restore jobs. +**None of them belongs to an app.** Giving both hooks one identity type would mean a struct whose +`App` field is empty at half its call sites, and an identity that is absent exactly where a reader +would go looking for it is worse than no identity at all. If the platform hook ever does need to know +what it is shaping, the answer will be an add-on instance or a collector kind — a different type, +reached by a different decision. + +### 5. The compile-time pin stays, and covers the new method too + +`placement_test.go` pins both hooks' signatures with method expressions, so that widening either one +to serve both kinds of pod stops compiling. That guard is the reason this record chose a new method +over an edited one, and it survives this change untouched: both pinned lines still hold. + +A third line is added pinning `WithAppPodMutator`, so the new spelling is guarded the same way the +other two are. The guard's comment is updated to say that the app hook now has two spellings over one +field — a pin whose comment describes a world that no longer exists is a pin the next reader will +distrust and then weaken. + +## Consequences + +**Nothing breaks.** No existing call compiles differently, in this repository or outside it. The six +in-repo test wirings, the signature pin, and any embedder that wired the original hook are all +untouched. An adapter with no mutator wired authors the same bytes it did before. + +**There are two spellings of one seam.** This is a real cost and the honest name for it is a wart. +[ADR-0073](0073-placement-policy-reaches-every-authored-pod.md)'s argument for two hooks was that they cover two different +*sets of pods*; these two cover the same set and differ only in what they are handed. What keeps it +tolerable is that there is only one *mechanism* underneath — one field, one invocation, one applied +policy — so a reader who finds either spelling finds the same behaviour, and the deprecation marker +tells them which to write. + +**The engine still wires neither.** `docs/CAPABILITIES.md`'s statement that nothing in this repository +wires a mutator remains true and should stay true. This record widens an extension point; it does not +give the engine a use for one. + +**An embedder gains the ability to differ between apps, and the responsibility that comes with it.** +A hook that can treat two apps differently can also treat them differently *by mistake*, and the +failure lands on a pod template rather than at a compile error. That risk is the embedder's to manage; +what this record does is make it possible to take it deliberately instead of impossible to take at +all. + +**A future identity need is a new field, not a new hook.** `PodIdentity` being a struct rather than +positional parameters means adding a fact about the pod later is source-compatible — subject to §2's +constraint, which rules out the kind of field most likely to be asked for. + +## Rejected alternatives + +**Change `WithPodMutator`'s signature outright, with a renamed shim for the old one.** Go has no +overloading, so the shim must be a differently-named method — which is this record's decision with the +names swapped, keeping the good name for the widened hook. It breaks every outside embedder at compile +time. That is the *loud* kind of break rather than the silent kind, and pre-1.0 the repository has +said elsewhere that breaking changes are acceptable. It was rejected because the break buys only a +name: the behaviour, the mechanism and the migration are identical either way, and it would also +require editing the signature pin, which reads as defeating a guard that exists to catch precisely +this change. + +**A per-app adapter view — `WithApp(...)` returning a copy whose mutator reads the app off the +receiver.** Rejected because the existing `WithNamespace` demonstrates the failure mode. It returns +the **receiver**, not a copy, when the requested namespace equals the one it already holds — and for +an embedder whose apps deploy to a default environment that is the common path, not an edge. A +"view" that is silently the shared object would let one deploy set a mutator that another deploy +reads, and the wrong app's pod would be shaped with no error and no failing test. A second +copy-on-write field would inherit the same trap. It also leans on `Adapter` being safe to shallow-copy, +which it is not in general: its controller placement holds a map, a slice and a pointer, and a struct +copy aliases all three. + +**Let the hook work out the app for itself, from a label or the container image.** This is the shape +[ADR-0073](0073-placement-policy-reaches-every-authored-pod.md) §2 already rejected: *"one hook could serve that only by +keying off an image or label to reconstruct a classification the engine already has, and a wrong +branch puts tenant code on the platform pool."* The engine holds `spec.App` at both invocation sites. +Making the operator re-derive what the caller already knows converts a fact into a guess. + +**Hand the mutator the whole `WorkloadSpec`.** Rejected on two counts. The two invocation sites do not +share a type — one has a `WorkloadSpec` and the other a `RunSpec` — so the run path would need a +synthetic `WorkloadSpec` invented for a Job, which is the move +[ADR-0077](0077-placement-policy-for-pods-burrow-does-not-author.md) §2 rejects for the controller path because *"it invents a +pod that never exists."* And `WorkloadSpec` carries the app's environment, its secret file mounts and +its secret env keys; putting all of that in front of a hook that needs a name and a namespace widens +what the seam exposes for no gain. + +**Carry the environment name in `PodIdentity`.** Rejected. Neither `WorkloadSpec` nor `RunSpec` has an +environment field today, so it would mean threading one through the engine to serve a consumer's +storage key — which is §2's constraint exactly. An embedder that needs an environment resolves it from +`(App, Namespace)` against its own records. diff --git a/docs/adr/README.md b/docs/adr/README.md index e51fc76d..5a78c066 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -150,7 +150,7 @@ so they are listed here: | [0058](0058-auto-deploy-is-opt-in.md) | Auto-deploy is opt-in — the default level is `off` — revises [ADR-0052](0052-pull-based-passive-deploy.md) §2 and §5, which made `minor` the default and turned auto-deploy **on for every app, new or already deployed**. That has a sharp edge on upgrade: when a cluster moves to a poller-carrying version, a **pre-existing app that never opted in immediately begins being polled**, and because the poller lists the registry anonymously with no read credential of its own, a private repository answers `401` and the watcher logs it every interval — recurring noise for an app the operator never asked to auto-deploy. The deeper problem the record names is not the log line but the surprise: **installing or upgrading Burrow silently changed what may deploy to an app**, and "what deploys unattended" is precisely the class of decision Burrow keeps in human hands — the same reason the agent cannot set the level. The on-by-default choice also contradicted ADR-0052's own title. Decision: **an app with no stored level row reads `off`**, so the watcher's per-app reconcile reads the level first and **returns before contacting the registry** — an app that never opted in is never polled, no tag listing, no `401`, and the noise is removed at its source rather than suppressed. Turning it on is a deliberate `burrow app auto-deploy ` with a level, per environment; until then only the explicit guarded deploy ships a release. **Only the default changes** — level semantics, the guarded path, the surfaced above-cap upgrade, the rollback safety stop, and the conservative cadence are all unchanged — and the release-notes migration ADR-0052 anticipated is no longer needed. The poller's read-credential story stays open and is named, with its listing failure now logged once per distinct error rather than every interval. Rejects keeping on-by-default, defaulting off for pre-existing apps and on for new ones (a hidden, time-dependent rule resting on state the operator cannot see), and suppressing the poller's registry errors instead of fixing the default | Accepted | | [0059](0059-oss-build-container-runs-privileged.md) | The build container runs privileged — **supersedes [ADR-0056](0056-build-security-context-for-the-oss-builder.md)'s mechanism**, which ADR-0056 itself asked to be pinned on a real cluster. Validated end to end on a live managed cluster (DOKS/containerd), the narrow set is **necessary but not sufficient**: it clears the `unshare(CLONE_NEWUSER)` wall it targeted, then fails on the next one — buildah's layer extraction (`chrootarchive`) remounts the container **root mount** private before pivoting, and a managed CRI creates that mount **locked**, so changing its propagation is refused. Isolating the cause in a throwaway pod mirroring the build context settled it conclusively: a **writable** root filesystem changes nothing (the block is the mount *lock*, not the read-only flag); **`CAP_SYS_ADMIN` changes nothing** (a namespaced `CAP_SYS_ADMIN` cannot change propagation on a mount the parent namespace locked, reproducible with plain `unshare -Urm` outside buildah entirely); and **`privileged: true` completes the build**. ADR-0056's narrowest-first ordering is sound in principle but **bottoms out here: on managed Kubernetes there is no rung below privileged that runs the build**. Decision: the build container runs `privileged: true`, `seccompProfile: Unconfined`, `allowPrivilegeEscalation: true`, and a **writable** root filesystem; the clone init container keeps the full floor. **ADR-0056's trust argument carries over unchanged and is what still makes this acceptable**, as does its seam-based isolation hook for the untrusted-source case, which never runs a privileged pod on a shared node. Because privileged is broad, **the bounding moves to namespace and lifetime**: a dedicated `burrow-builds` namespace isolated from both the app and control-plane namespaces (so a build cannot reach a running app's Secrets or burrowd's credentials and database), a non-root UID, resource caps, and a transient TTL-reaped Job — with the Pod Security Admission `privileged` label scoped to that one namespace. The adapter also pins buildah's storage explicitly (a private vfs graphroot and runroot under `$HOME` at `0700`, a private `XDG_RUNTIME_DIR`, a writable `TMPDIR`), because the default falls back to a group-writable path buildah refuses. Rejects ADR-0056's set, `CAP_SYS_ADMIN`, and writable-root-alone (all validated insufficient), and defers `hostUsers: false` user namespaces until dependable across providers | Accepted | | [0060](0060-cluster-lifecycle-command-group.md) | `cluster` is the cluster-lifecycle command group; operate verbs stay portable — an inconsistency had opened up: `bootstrap` is install-plus-baseline and lived under `burrow cluster` alongside `ingress`, `registry`, and `capacity`, while plain `install` and `upgrade` sat at the top level next to the operate verbs, so two commands doing nearly the same job lived at different altitudes. The deeper line the record draws is **self-hosted-only versus portable**: installing or upgrading the control plane, provisioning ingress, and reading raw cluster capacity all **drive a kube context**, which only a self-hoster has, whereas the operate verbs, `env`, `agent`, `config`, `guard`, and `audit` speak to a control plane **over its API** — and that control plane can equally be one the user self-hosts or one run on their behalf, reached generically with the same client given an endpoint URL and a token ([ADR-0045](0045-oss-enterprise-boundary.md)). Decision: `install` and `upgrade` move to **`burrow cluster install` / `burrow cluster upgrade`**, joining the existing cluster commands, with bare `burrow cluster` keeping its read-only capability report and the getting-started guidance and first-run banner repointed. The portable commands stay top level, which means **the top-level surface is now exactly the portable one**, so **the CLI needs no mode switch and its command surface reveals nothing about how it is being reached**. The old spellings remain as **deprecated, hidden top-level aliases** that delegate to the same command constructor and print a one-line migration hint, so scripts and muscle memory keep working through the transition; Cobra excludes deprecated commands from the main help, so they execute without cluttering it. Rejects leaving `install`/`upgrade` at the top level (keeps the `bootstrap` inconsistency and mixes self-host-only commands into the portable surface), removing the old spellings outright (a needless courtesy break even pre-1.0), and adding a mode flag or a mode-specific command group | Accepted | -| [0061](0061-deploy-pod-mutator-seam.md) | A pod-mutator seam on the deploy path, mirroring the build one — `Adapter.WithPodMutator(func(*corev1.PodSpec))`, applied to the Deployment's pod template **after it is constructed and before the object reaches the API server**, with deliberately the same signature, the same nil-means-unchanged default, and the same naming as [ADR-0053](0053-in-cluster-build-from-source.md) §6's `WithBuildPodMutator`, so a reader who understands one understands the other and there is no second concept to learn. The problem is that the app pod is built as a **fixed literal** nothing outside the package can adjust, and **that shape is not universally deployable**: a cluster whose only schedulable capacity is **tainted** (a GPU pool, spot capacity, a pool held for one team) admits a pod only if it carries the matching **toleration**, and Burrow's app pods carry none — so the deploy stays `Pending` forever with no way for the operator to fix it. The same applies to a mandated `runtimeClassName`, a priority class, topology constraints, a `nodeSelector`, or a pull secret for a private base registry. None of these belong in the engine — they are properties of *a* cluster, not of Burrow — but a fixed literal makes them **impossible rather than merely unspecified**, and the asymmetry is the anomaly: the same operator can already adjust the **build** pod and not the app pod, and the app pod is the one that runs indefinitely. **§2 applies the mutator on update as well as create**, because a create-only hook is silently dropped by the first rollout — a regression that surfaces later, under load, presenting as a scheduling problem rather than a missing hook. §3 makes "a nil mutator leaves current output byte-for-byte unchanged" a **test obligation**. The consequences are named honestly: the hook is trusted, in-process, and unvalidated; a non-idempotent mutator will drift because it runs on updates (appending to a slice is the obvious trap); and two installs on the same version can now produce different pods, which makes the mutator the first thing to inspect when they differ. Rejects forking the deploy path downstream (drift in the most churn-prone code), **patching the Deployment after creation — a race, since pods can start before the patch lands, so where the missing field is a sandboxed runtime the window is a workload running unsandboxed** — a mutating admission webhook (another component, its own certificate lifecycle, a down-webhook failure mode, imposed on installs needing none of it), per-field configuration on the engine (a list with no natural end), and a whole-object mutator (wider than the need) | Accepted | +| [0061](0061-deploy-pod-mutator-seam.md) | A pod-mutator seam on the deploy path, mirroring the build one — `Adapter.WithPodMutator(func(*corev1.PodSpec))`, applied to the Deployment's pod template **after it is constructed and before the object reaches the API server**, with deliberately the same signature, the same nil-means-unchanged default, and the same naming as [ADR-0053](0053-in-cluster-build-from-source.md) §6's `WithBuildPodMutator`, so a reader who understands one understands the other and there is no second concept to learn. The problem is that the app pod is built as a **fixed literal** nothing outside the package can adjust, and **that shape is not universally deployable**: a cluster whose only schedulable capacity is **tainted** (a GPU pool, spot capacity, a pool held for one team) admits a pod only if it carries the matching **toleration**, and Burrow's app pods carry none — so the deploy stays `Pending` forever with no way for the operator to fix it. The same applies to a mandated `runtimeClassName`, a priority class, topology constraints, a `nodeSelector`, or a pull secret for a private base registry. None of these belong in the engine — they are properties of *a* cluster, not of Burrow — but a fixed literal makes them **impossible rather than merely unspecified**, and the asymmetry is the anomaly: the same operator can already adjust the **build** pod and not the app pod, and the app pod is the one that runs indefinitely. **§2 applies the mutator on update as well as create**, because a create-only hook is silently dropped by the first rollout — a regression that surfaces later, under load, presenting as a scheduling problem rather than a missing hook. §3 makes "a nil mutator leaves current output byte-for-byte unchanged" a **test obligation**. The consequences are named honestly: the hook is trusted, in-process, and unvalidated; a non-idempotent mutator will drift because it runs on updates (appending to a slice is the obvious trap); and two installs on the same version can now produce different pods, which makes the mutator the first thing to inspect when they differ. **Refined by [ADR-0100](0100-a-pod-mutator-learns-which-apps-pod-it-is-shaping.md)** (§1's signature gains a second spelling carrying the app's identity; §2 and §3 stand unchanged). Rejects forking the deploy path downstream (drift in the most churn-prone code), **patching the Deployment after creation — a race, since pods can start before the patch lands, so where the missing field is a sandboxed runtime the window is a workload running unsandboxed** — a mutating admission webhook (another component, its own certificate lifecycle, a down-webhook failure mode, imposed on installs needing none of it), per-field configuration on the engine (a list with no natural end), and a whole-object mutator (wider than the need) | Accepted | | [0063](0063-object-storage-provider.md) | Object storage is a provider type, scoped to being a backup destination — [ADR-0032](0032-postgres-backups.md)'s dumps land on a PersistentVolumeClaim in the **same cluster as the database**, so they share its failure domain: they survive a bad migration but not the loss of the cluster or the volume, and the missing schedule and retention both need the same thing first — a destination that is not this cluster. Object storage becomes a provider *type* on [ADR-0023](0023-provider-credentials.md)'s existing registry: same `burrow-credentials` Secret, same `providers` table, same `resourceNames`-restricted `get`, **no new credential mechanism and no new RBAC**. Addressed by **S3-compatible endpoint rather than by vendor**, so the vendor is a configuration value and not part of the architecture. **The substance of the record is the SCOPE, not the feature.** Every vendor ships a capable object-storage CLI and one of them is enormous; Burrow must not become a worse one, and the record enumerates what it deliberately does not do (no `cp`/`sync`/`ls` of arbitrary prefixes, no presigned URLs, no bucket policy/IAM/replication surface) so that widening it is an amendment rather than a drift — a capability enters only when a Burrow feature requires it, never because the API offers it. What Burrow owns is the seam those CLIs cannot see: putting the credential where the backup engine will look for it (a Secret, in a namespace, in a shape, named by a CR field — which is where the time and the mistakes go); **verifying the destination at configuration time** by writing and deleting a probe object, so a wrong key fails now and loudly rather than at the first scheduled backup, silently; and §3's load-bearing invariant, **reconciling bucket lifecycle against backup retention** — a lifecycle rule that expires objects sooner than the oldest backup needs them leaves a backup set that lists fine and cannot be restored, and neither the vendor's CLI (which knows nothing about backups) nor the backup engine (which does not own the bucket policy) can see both halves. Where Burrow cannot read a bucket's lifecycle configuration it says so rather than implying it checked, because an unverifiable invariant reported as verified is worse than one reported as unknown. §4 guardrails bucket creation and deletion, deletion **denied by default** (its blast radius is every backup the platform holds). §5 makes one registration serve every consumer, and permits several providers, since the registry is a table rather than a singleton. Notes the wrinkle that an S3 credential is a **pair** where ADR-0023's Secret holds one opaque token per provider. Leaves three sub-decisions explicitly open: how that pair is stored (settle before the first provider ships — changing it later is a credential rotation for every user), whether bucket *creation* belongs at all or only bucket *use*, and what happens when the store is unreachable at backup time. Rejects telling users to run the vendor's CLI (does not wire the credential, reconcile lifecycle, or make the operation agent-safe), a full object-storage command surface (a second-rate S3 client with no natural end), supporting one vendor properly (makes the vendor a dependency of the architecture), keeping backups on a PVC with volume snapshots (same failure domain; snapshot retention is the operator's problem), and a dedicated Secret separate from `burrow-credentials` (forks ADR-0023's model for one provider type; scope the credential at the vendor instead) | Accepted | | [0064](0064-addon-removal-keeps-its-data.md) | Removing an add-on keeps its data; destroying the volume is a separate, operator-only act — **supersedes [ADR-0031](0031-postgres-addon.md)'s Teardown section**, which deleted the PVC on removal. For the Postgres add-on that is EVERY attached app's database, and the confirmation said only `removing the add-on "burrow-postgres"` — gated, but not informed. Two aggravating facts: remove-and-reinstall is the obvious REPAIR move, so the recovery-shaped use of the verb was its most destructive one; and the blast radius is not the add-on's own, since apps whose data lives in that volume did not participate in the decision. Removal now tears down the Deployment/Service/config and **leaves the claim**, so a reinstall picks the data back up and attached apps reconnect on their existing `DATABASE_URL` (role passwords live in PGDATA). Destroying it needs `--delete-data`, **absent from the agent binary** rather than denied — a disposition is a row someone can change ([ADR-0065](0065-what-belongs-on-the-agent-surface.md) tier 1) — plus, on a terminal, **typing the add-on's name** after a warning enumerating the volume and the affected apps, refusing outright off a terminal unless told otherwise. The prompt is explicitly **not** a security control: anything with a shell can type a word, stated so the structural absence is never relaxed on the grounds that a confirmation exists. Enumeration is best-effort and never blocking, because an add-on is often removed precisely BECAUSE it is wedged and an unremovable broken add-on is the worse failure. §4 keeps the backup claim always, including under `--delete-data` — backups outliving their source is the point, and it is what makes the destructive path survivable. §5 takes a final backup FIRST where object storage is configured (ADR-0063), aborting if it fails, so nothing is destroyed until a copy is known to exist; it cannot be mandatory, since a wedged instance cannot be dumped and requiring it would trade data loss for an undeletable cluster. §6 lists retained volumes, which is what makes keep-by-default defensible — a bill is a worse way to learn about a volume than a listing — and no automatic reaper is added, since a timer that deletes the data this record protects reintroduces the failure by a slower route. Records two discovered facts: the backup claim ALREADY survived removal (accidental and correct), and keeping the volume while regenerating the superuser Secret would be a trap, because the official image honours `POSTGRES_PASSWORD` only during `initdb` — a database present and permanently unauthenticable — so Secret and volume share a fate. Leaves open whether `--delete-data` deserves its own guardrail code, and whether the listing should report cost or only size. Rejects keeping the delete and improving the prompt (a poor defence for an irreversible act, and the users most likely to lose data are the ones debugging), hard-refusing while apps are attached (makes a wedged add-on unremovable when removal is the repair), putting the flag on the agent surface behind a deny (a row versus an absent verb), and deleting the backup volume too (removes the only path back from the most destructive flag in the product) | Accepted | | [0065](0065-what-belongs-on-the-agent-surface.md) | What belongs on the agent surface — three tiers, by blast radius and reversibility. **Refines [ADR-0049](0049-burrow-agent-scoped-cli-control-channel.md)**, which established that the agent gets its own narrow CLI but never said what QUALIFIES a command to be on it, so each addition was argued from scratch and the surface drifted. The pattern is otherwise clean — `attach` without `detach`, `backup` without `restore` — with one exception: `addon remove`, the single most destructive verb in the product, because add-ons are **one per type per cluster** (`InstallAddon` takes a *type*; `addons.name` is its primary key) and ADR-0031 puts every app's database on that one shared Postgres. It removes THE add-on, not AN add-on, and there is no configuration in which its blast radius is small. Two questions decide placement: does the effect reach beyond the app the agent was asked about (**scope**), and can a human restore the prior state (**reversibility**). Failing scope is disqualifying — tier 1, **absent from the binary**, asserted by the surface-guard test. Failing only reversibility means the operator decides — tier 2, **denied by default** as a guardrail disposition. Routine-but-consequential stays tier 3, **confirm** (`app.run`, which is ADR-0048's whole purpose). Changes: `addon remove` leaves the binary; `app.delete` and `dns.delete` move `confirm` → `deny`. **Tier 2 is preferred to tier 1 wherever risk allows**, and the reason is behavioural rather than aesthetic: a denied verb is legible and anticipatable through the read-only `guard` command, while an absent one yields `unknown command` — a dead end that invites an agent to route around the control channel entirely, the failure burrow ADR-0021 says Burrow cannot close from the inside. §7 therefore has `guard` report ABSENT capabilities too, so an agent can tell a human "this is not something I can do, and here is who can"; it enumerates the surface, accepted because `--help` already does. A tier-2 default is a **floor, not a fixed setting**: `app.*` codes are environment-scopable, so the expected shape is a gradient — allow in dev, confirm in staging, deny in prod. Names the limitation honestly: `EnvScopable` keys on the `app.` prefix, so `dns.delete`'s deny is **cluster-wide and all-or-nothing**, which argues for widening environment scoping beyond `app.` in a separate change. Load-bearing dependency, stated: the middle tier holds only while `guard set` stays off the agent surface — and `guard set` is currently **unaudited**. Accepts that the agent can create apps it cannot delete, so they accumulate until a human intervenes: the same trade as ADR-0064's retained volumes, since an unnecessary app costs money and a wrongly deleted one costs data. Rejects removing every destructive verb (forecloses legitimate operator choice and produces dead ends), leaving everything at confirm (a control only for someone who reads it, and these are the cases where the reader is mid-incident), a single "destructive" flag (collapses unbounded blast radius and mere irreversibility, which warrant different mechanisms), and per-command policy (a second policy surface beside guardrails) | Accepted | @@ -161,11 +161,11 @@ so they are listed here: | [0070](0070-implementation-status-lives-in-issues.md) | Implementation status lives in issues, created on acceptance — ADRs record decisions and never whether code exists ([ADR-0009](0009-honest-status.md)), which left *is this built?* answered in **three hand-maintained prose documents**: `ROADMAP.md`'s "Decided, not yet built", `CAPABILITIES.md`'s table, and `PLAN.md`'s sequencing. They drift independently, and every drift found in the 2026-07-27 sweep was prose left behind by a change rather than a disagreement about code — the README's shipped list naming a Proposed decision refused at runtime, `CAPABILITIES.md` calling an accepted ADR Proposed, both docs describing a released version as unreleased. The failure is **silent**: a stale sentence looks exactly like a current one, and the reader most likely to be misled is the one least able to tell. **An issue closes itself when a PR says `Closes #N`; a sentence does not — that is the whole argument.** §2 requires **one issue per implementable unit** rather than per ADR, which is what makes partial implementation representable (ADR-0064 shipped four sections of six, ADR-0065 one change of three; under one-issue-per-ADR each is either open or closed and neither is true). §3 has the issue name the ADR and **never the reverse**, since an accepted record cannot be edited and one accruing pointers to its own implementation is tracking status again. §4 opens issues only **after** acceptance: drafts change materially in review, and an open issue creates momentum toward accepting a record rather than examining it. §5 uses GitHub `blocked_by` for sequencing instead of prose. §6 **trims rather than deletes** — a repository should describe itself to someone reading it offline, so themes and sequencing stay in-repo and per-unit granularity moves out. §7: a bug does not wait for an ADR. Names the costs: some self-description moves to a forge a clone does not carry; issues still go stale when an ADR is superseded; acceptance gains a step whose omission leaves work invisible; and the `adr` label becomes load-bearing. Rejects more discipline with the prose trackers (discipline is what failed, in three places at once), a single prose tracker (still cannot close itself), a richer Status line in the ADR (makes accepted records mutable), a skipped test naming the ADR (most decisions are not one assertion), and deleting the prose entirely | Accepted | | [0071](0071-a-deny-gates-the-operator-too.md) | A `deny` disposition gates the operator too, and deletion becomes a two-step act — **corrects a factual claim in [ADR-0065](0065-what-belongs-on-the-agent-surface.md) §3**, which said the denied verbs "remain fully available to the human operator CLI, which these dispositions do not gate." That is false: guardrails are evaluated in `Engine.DeleteApp`/`Engine.RemoveDomain`, **server-side**, and both CLIs reach them through the same API — `--confirm` satisfies a *hold*, not a *denial* — so `burrow app delete web --confirm` is refused. §1 confirms the resulting behaviour as intended rather than accidental: deleting an app is `guard set --env prod app.delete confirm` then delete, two deliberate steps, which is proportionate for an operation that destroys release history irreversibly and leaves the operator with a policy reflecting what they want rather than a one-off override. §2 declines the alternative that would have made the sentence true — a **caller-aware bypass** puts a new trust dimension inside the guardrail evaluator, the control plane deciding per request whether the caller is human and being right, which is an authentication question wearing a policy question's clothes and whose failure mode is a denial that does not deny; [ADR-0006](0006-guardrails-in-the-control-plane.md) keeps guardrails deterministic. The separation that already exists is the correct one: **the verb is gated for everyone, the lever is gated by structure**, since `guard set` is absent from the agent binary. §3 puts the correction in a new record because ADR-0065 is Accepted and a wrong claim is neither a typo nor a dead link — the distinction between repairing a link and rewriting a claim is what makes immutability mean anything. Consequences note that an operator's cheapest keystroke is still the cluster-wide relaxation; that ADR-0065 now contains a sentence known to be false, which puts more weight on the index row and `CAPABILITIES.md`; that `dns.delete` is worse affected since `EnvScopable` keys on the `app.` prefix so its relaxation is necessarily cluster-wide until [ADR-0068](0068-operational-limits-are-configuration.md) §5 lands; and that **per-principal roles** are the eventual principled answer and deliberately not this — burrowd deciding what a named principal may call, groundwork already laid by [ADR-0038](0038-scoped-agent-credential.md)'s principal seam, a larger surface than this product needs today. Rejects the caller-aware bypass, reverting `app.delete` to `confirm` (which depends on the agent cooperating with a hold — the property ADR-0065 declined to rely on for an irreversible operation), amending ADR-0065 in place, and letting `CAPABILITIES.md` carry the correction alone | Accepted | | [0072](0072-deploy-and-run-lifecycle-hooks.md) | Lifecycle hooks named for when they run, and told how it went — auto-deploy ([ADR-0052](0052-pull-based-passive-deploy.md)) ships an image with **nobody present** and there is **no hook at any phase**, so a user who enables it and changes their schema has no supported way to migrate (their options are an entrypoint migration that races itself across replicas and re-runs on every restart, a hand-written Job needing `kubectl` and the app's Secret — routing around the control plane and the scoped credential that makes an agent safe to point at production — or turning the feature off) **and no way to hear that the deploy then crashlooped**. Adds `pre-deploy`, `post-deploy` and `pre-rollback` as ONE command with a phase you name. §1 rejects the Heroku word **"release"**: in every other tool that means the *artifact* — a tag, a changelog, the record of what shipped — so reusing it asks the reader to learn a second meaning while failing to answer the only question they have, *when does my command run*, which a phase name answers for free. §2 runs `pre-deploy` on **every** deploy path including explicit ones (a hook that fires only sometimes defeats the point that schema and code move together) from the **new** image before traffic moves; §3 makes its failure **abort the deploy**, so a migration failure is a deploy that did not happen rather than one that half-happened. §4 is the addition that makes the unattended path legible: `post-deploy` receives the **outcome** and, on failure, the machine-readable **reason** from [ADR-0074](0074-burrow-observes-what-it-manages.md) §2's closed vocabulary — and **runs whether it succeeded or failed**, because a post hook that fires only on success cannot report the case it exists for. That dependency is real and dated: before ADR-0074 widened the vocabulary an unavailable workload reported `Available: false` and an empty reason, so a hook could have been told *that* a deploy failed and never *why* — **a hook that knows only "something went wrong" is a notification, not an integration**. §5 bounds the settle-wait and insists the expiry reports **the reason Burrow observed, not a bare timeout** — #352's shape, where a waiter burns its deadline and reports elapsed time while the cluster was saying "unschedulable" the whole time, converting a diagnosis into a shrug — with the bound belonging in [ADR-0068](0068-operational-limits-are-configuration.md)'s configuration rather than a constant. §6 keeps Burrow from **rolling back by itself**: it reports and the hook decides (ADR-0074 §9's restraint in a second place — the remedy is a judgement about blast radius and data, and an automatic rollback after a `pre-deploy` migration has run can leave the schema ahead of the code it just restored). §7 states the limit a reader will otherwise assume away: **there is no readiness probe on a user application** (`ReadinessProbe` appears once in the tree, on the add-on path), so Kubernetes marks a pod ready as soon as its container starts and **an app that boots, listens and returns 500 to every request is a "successful" deploy** — meaning a smoke test is the natural `post-deploy` hook, and Burrow tells the hook the deploy *happened* while the hook decides whether it *worked*. §8 keeps `pre-rollback` optional and **defaulting to nothing**, running from the image being rolled back **FROM** — rolling back B→A, the code that knows how to undo B's migration is in B, and A's tool would step back one of *A's* own migrations instead, which is worse than doing nothing. Names the costs: **three phases is modest surface**, expand/contract is still not expressible unattended, a `pre-rollback` that is set and wrong runs a schema change during an incident from an abandoned image, and a bad `pre-deploy` is a new way for an app to become undeployable. Rejects the name "release", doing nothing, replacing `burrow run` with a hook (ADR-0048's rejection stands), **`pre-run`/`post-run` hooks** (the argument for hooks is that auto-deploy has *no caller*, and `burrow app run` is synchronous and always has one — the caller sees the exit code and can sequence what follows, which ADR-0048 says is exactly what the explicit call is for, so a hook there duplicates the caller; symmetry is not a justification when the argument does not apply), **pre-only with no post phase** (leaves the unattended path as silent as today for everything after the deploy starts — the failure a 3am push actually produces is a crashloop, not a failed migration), **a post hook that runs only on success** (self-defeating), **automatic rollback**, an init container (per-pod, so replicas race), running the migration after the deploy (a live outage), tool detection, one command for both directions, and **adding a readiness probe here** (a different decision whose own failure mode is that a badly-configured probe turns a working deploy into a failed one) | Accepted | -| [0073](0073-placement-policy-reaches-every-authored-pod.md) | Placement policy reaches every pod the engine authors, split by whose image runs — [ADR-0061](0061-deploy-pod-mutator-seam.md)'s argument was never about Deployments but about **the gap between a fixed pod literal and a real cluster's admission and scheduling constraints**, yet the seam was built for one path. The engine authors **six**: app Deployment and one-off run Job (`WithPodMutator`), build Job (`WithBuildPodMutator`, ADR-0053 §6), and **four with nothing at all** — backup **and** restore Jobs, the Postgres add-on, the metrics collector — plus a log-collector DaemonSet carrying a hard-coded blanket toleration. So **on the exact tainted-pool cluster ADR-0061 was written for**, an operator who followed it has working deploys and a backup that never runs, and **each of the four fails quietly**: a Pending Job leaves `Failed` and `Succeeded` both zero, so a waiter burns its full timeout and reports a *timeout* rather than an unschedulable pod, and a Pending add-on reads as a slow start. **The restore is the worst case** — it shares the backup's builder, so the discovery arrives during an incident. §1 makes the rule general and standing: a new authored pod path arrives with a hook or arrives undeployable. §2 splits the reach **by whose image runs** — `WithPodMutator` for the app's own image, a new `WithPlatformPodMutator` for Burrow's — because a managed operator wants the tenant's image sandboxed on tenant nodes and their own Postgres and collectors where the tenant's code is not; **one hook could serve that only by keying off an image or label to reconstruct a classification the engine already has**, and a wrong branch puts tenant code on the platform pool. §3 keeps the DaemonSet's blanket toleration as the one place a hard-coded placement field is a decision rather than an omission — a collector that skips tainted nodes silently loses those nodes' logs — while still applying the hook. §5 states what the seam is **not**: **wiring nothing sandboxes nothing**, so the fix that prompted this must not be read as "the engine now sandboxes runs" — it makes the operator's sandboxing *reach* them, and enforcement belongs to admission policy, not to a hook the same binary can decline to wire. Names the costs: two seams the engine never uses, wiring one of two is a silent partial, and the platform hook reaches **stateful** workloads, so a mutator that moves the Postgres pod where its volume cannot attach breaks the add-on rather than one deploy. Rejects one hook over everything (§2), a hook per path (exposes internal decomposition as public API for a distinction the operator has no policy about), per-field configuration (ADR-0061's endless list), **a mutating admission webhook** — the one option that would make isolation genuinely *enforceable*, rejected for ADR-0061's reasons and named as the right answer for an operator who needs enforcement — hard-coding what the managed product needs, and waiting for a report (which arrives from someone who cannot restore a database) | Accepted | +| [0073](0073-placement-policy-reaches-every-authored-pod.md) | Placement policy reaches every pod the engine authors, split by whose image runs — [ADR-0061](0061-deploy-pod-mutator-seam.md)'s argument was never about Deployments but about **the gap between a fixed pod literal and a real cluster's admission and scheduling constraints**, yet the seam was built for one path. The engine authors **six**: app Deployment and one-off run Job (`WithPodMutator`), build Job (`WithBuildPodMutator`, ADR-0053 §6), and **four with nothing at all** — backup **and** restore Jobs, the Postgres add-on, the metrics collector — plus a log-collector DaemonSet carrying a hard-coded blanket toleration. So **on the exact tainted-pool cluster ADR-0061 was written for**, an operator who followed it has working deploys and a backup that never runs, and **each of the four fails quietly**: a Pending Job leaves `Failed` and `Succeeded` both zero, so a waiter burns its full timeout and reports a *timeout* rather than an unschedulable pod, and a Pending add-on reads as a slow start. **The restore is the worst case** — it shares the backup's builder, so the discovery arrives during an incident. §1 makes the rule general and standing: a new authored pod path arrives with a hook or arrives undeployable. §2 splits the reach **by whose image runs** — `WithPodMutator` for the app's own image, a new `WithPlatformPodMutator` for Burrow's — because a managed operator wants the tenant's image sandboxed on tenant nodes and their own Postgres and collectors where the tenant's code is not; **one hook could serve that only by keying off an image or label to reconstruct a classification the engine already has**, and a wrong branch puts tenant code on the platform pool. §3 keeps the DaemonSet's blanket toleration as the one place a hard-coded placement field is a decision rather than an omission — a collector that skips tainted nodes silently loses those nodes' logs — while still applying the hook. §5 states what the seam is **not**: **wiring nothing sandboxes nothing**, so the fix that prompted this must not be read as "the engine now sandboxes runs" — it makes the operator's sandboxing *reach* them, and enforcement belongs to admission policy, not to a hook the same binary can decline to wire. Names the costs: two seams the engine never uses, wiring one of two is a silent partial, and the platform hook reaches **stateful** workloads, so a mutator that moves the Postgres pod where its volume cannot attach breaks the add-on rather than one deploy. **Refined by [ADR-0100](0100-a-pod-mutator-learns-which-apps-pod-it-is-shaping.md)** (the app hook can now be handed *which* app, so a per-app policy no longer has to re-derive one from an image or a label — §2's split by whose image runs is unchanged). Rejects one hook over everything (§2), a hook per path (exposes internal decomposition as public API for a distinction the operator has no policy about), per-field configuration (ADR-0061's endless list), **a mutating admission webhook** — the one option that would make isolation genuinely *enforceable*, rejected for ADR-0061's reasons and named as the right answer for an operator who needs enforcement — hard-coding what the managed product needs, and waiting for a report (which arrives from someone who cannot restore a database) | Accepted | | [0074](0074-burrow-observes-what-it-manages.md) | Burrow observes what it manages, and remembers when it broke — Burrow can say an app is not available; it usually cannot say **why**, and it can never say **when it started**. `WorkloadStatus` carries an `Issue` field built for exactly this — actionable prose plus a machine-usable reason — populated in **one function for one failure class**, a failed image pull, and `imagepull.go` says so in the code: *"These are the only reasons Burrow surfaces as an Issue."* Every other blocking condition — a pod that never scheduled, a `CrashLoopBackOff`, a missing config key, an OOM kill, a volume that will not attach — reports `Available: false` with an **empty** `Issue`, so the surface built to spare users `kubectl` goes quiet on most of what actually breaks; Jobs are not in the surface at all, and add-ons, ingress and certificates have none. And **there is no watch and no informer anywhere in the tree**: every status is a question asked at the moment someone asks it, so a failure with nobody present is not recorded but merely missed — an unschedulable Job reports a *timeout* (#352), and a crashloop that healed at 02:40 is by morning indistinguishable from a week of uptime, because Events expire in an hour. §1 is the design: **current state is derived and never cached** (a cache is stalest during the incident it exists to help, and would report a healthy app that is down) while **history must be observed and stored**, because nothing reconstructs it afterwards. §2 widens the Issue vocabulary to the blocking classes, keeping the criterion the code already applies — blocking and human-fixable, not self-resolving — and is **separable and sequenced first**: no new component, no schema, and it removes most reaches for `kubectl`. §3 gives burrowd an observer, **the first thing in Burrow that runs when nobody asked it to**, scoped by the registry rather than a label. §4 records a failure as a **transition, not an event stream** — one row per object and reason with first-seen, last-seen, resolved-at and a count, so a pod that restarted four hundred times is one row saying four hundred — with bounded retention, because unbounded growth in the control plane's own database is an outage. §5 is built for the fact that failures arrive in bunches: keying on object AND reason is what lets one pod be OOM-killed and unschedulable at once without either being lost, and what makes a pod flapping between two reasons legible as the one bug it is — while the harder case is the reverse, one taint and twenty unschedulable pods, so the listing groups by shared reason and orders oldest-first, since the earliest row in a cascade is usually the one worth fixing. It stops there: **Burrow shows correlation and refuses to claim causation**, because a confidently wrong root cause during an incident is worse than none — and because that refusal is a **division of labour, not a shortfall**: Burrow does not run a model and must not acquire one, so its half is to report every failure completely in a shape something else can reason over, while **the agent's half is to turn twenty rows into a cause and a fix**, which makes completeness matter more than tidiness and puts an inference engine inside burrowd explicitly out of scope — and grouping is presentation, so an agent reading the API gets the rows and correlates on its own terms. §6 diffs **intent against the cluster** — a registered app with no Deployment, a `pending` backup whose Job is gone — the one diagnosis `kubectl` structurally cannot make, since the evidence is an absence. §7 keeps it **out of the audit log**: audit records what Burrow was asked to do and decided, this records what the cluster did afterwards, and merging them would subject an append-only security record to retention pruning. §9 **observes without remediating** — restarting a crashlooper is a mutation with nobody present, and the remedies for the failures that most invite it (OOM, unschedulable) are usually wrong. Names the costs: burrowd stops being request/response, a watch over every managed pod is real API-server load, and **a gap in the ledger reads as health unless observation coverage is itself recorded**. Rejects §2 alone (cannot answer "when did this start"), Kubernetes Events (expire, are the `kubectl` reach being removed, and cannot express §6), metrics or [ADR-0026](0026-observability-query-adapters.md)'s query seam (a state question, not a rate question — and the seam queries backends the user connected about the *app's* telemetry, which may not be installed at all), inferring root cause from a dependency graph (incomplete — it knows registered add-on bindings, not that app A calls app B — so the inference would be confident and sometimes wrong, in the worst possible place), caching current state, an event stream, folding it into the audit log, auto-remediation, and a CRD-based controller (status subresources are current-state only, and it would store the record of a failure inside the failing system) | Accepted | | [0075](0075-a-green-pr-is-a-tested-pr.md) | A green PR is a tested PR — the merge queue goes, the integration job moves — `main` is governed by a merge queue whose central purpose, **serializing concurrent merges**, is not a problem two committers on non-conflicting PRs have; what it costs is a **five-minute floor on every merge** (`min_entries_to_merge_wait_minutes`), an **eviction** failure mode (the queue evicts on a red merge-group run rather than retrying, so a k3d flake that self-reports "safe to rerun" drops a PR out permanently), and a class of failure that is **invisible from the pull request** — a PR reads `AWAITING_CHECKS` whether the queue is healthy or failing, so finding out means listing runs on `gh-readonly-queue/main/pr-N-*`. But the queue **cannot simply be deleted**, and that is the record: `kube-integration` is gated `github.event_name == 'merge_group'`, so **the queue is the only place the k3d suite ever runs**, and removing the queue alone would stop the integration suite running before merge entirely while leaving every check green — the same always-true-condition shape as the docs-only path filter #360 had just fixed. A second quieter dependency: `strict_required_status_checks_policy` is **false**, so a PR is never required to be current with `main`, making the queue also the only thing that tests the **merged** result rather than the branch. §1 removes the `merge_queue` rule and nothing else from the ruleset — signatures, linear history, PR requirement and status checks all stay, since this removes a merge mechanism, not a protection. §2 moves `kube-integration` to pull-request checks **in the same change, not as a follow-up**, because the two edits are one change or the repository is worse off than before. §3 keeps branches non-strict and fixes `main` forward: requiring branches to be current would force a rebase and a full re-run per merge, serialized — **a merge queue rebuilt by hand, with the automation removed and the cost kept**. §5 writes down the trigger to reconsider, and it is **concurrency, not team size**: two humans on separate PRs are not concurrent merges, but several agent branches racing to land are, and this project already sequenced three cloud issues that would have collided. Names the costs plainly: the suite runs on every code push rather than once per merge (three pushes pay three times, ~10 min each), affordable only because #360 made the docs-only skip genuinely work; and **a PR green on a stale base can break `main`**, so the branch becomes fixed-forward rather than green by construction — a real regression in guarantee, accepted deliberately. The gain is that **a green PR becomes a tested PR**, which today it is not: on a PR the integration job reports `SKIPPED`, the aggregate `pr-checks` gate treats that as fine, and the PR goes green having never touched a Kubernetes API server — [ADR-0009](0009-honest-status.md)'s dishonesty, in CI. Rejects keeping the queue (its guarantee is paid for on every merge and on the operational surface, to insure against a case two careful committers were not going to produce), **removing the queue and leaving the job gated on `merge_group`** (named explicitly as the trap rather than merely avoided), enabling `strict` checks, a scheduled nightly run against `main` (catches breakage after it lands and makes attribution a chore a per-PR run does for free), a queue only for code PRs (two merge paths, invisible failures retained for exactly the changes most likely to hit them), and merely zeroing the wait (the floor is the least of the costs) | Accepted | | [0076](0076-health-checks-readiness-only-and-dependencies-at-deploy-time.md) | Health checks — readiness only, and dependencies checked at deploy time — Burrow sets **no probe on a user application** (`ReadinessProbe` appears once in the tree, on the add-on path), so Kubernetes marks a pod ready the moment its container starts and **an app that boots, binds its port and returns 500 to every request is a successful deploy** — the gap [ADR-0072](0072-deploy-and-run-lifecycle-hooks.md) §7 named and declined to fix there. §1 sets **readiness only and never liveness by default**: readiness removes a pod from service, but liveness **restarts the container**, so a wrong one — too tight a timeout, a slow start under load — kills a working process repeatedly and presents as `CrashLoopBackOff`, **manufacturing the exact failure it was installed to detect**, under load, when it is least welcome. §2 is the rule most likely to be broken by someone trying to help: **a readiness probe never checks an external dependency**, because if every app's readiness tested the shared database then one blip would fail **every replica of every app simultaneously**, Kubernetes would pull them all from their Services, and a dependency that was merely degraded becomes a total outage that recovers slower than the original blip — worse here than elsewhere precisely because the database is *shared*, making the correlation total rather than partial. Readiness answers "can **this pod** serve?", a property of the pod, never "is the system healthy". §3 keeps the default **conservative and absent where it would be a guess**: a known port (`ExposeSpec.Port`, so published apps) gets a **TCP** check; an unknown port gets **nothing**. No port scanning, no assuming 8080, no guessing `/healthz` — **a probe Burrow invented is worse than no probe**, because it fails a working deploy and the user cannot tell whether their app is broken or Burrow's guess is; today's failure is silent success, and the failure introduced by guessing is loud and wrong. §4 puts the checks the maintainer actually wants — *can I reach my database, can I write to my volume* — at **deploy time** rather than in the probe, on ADR-0072 §4's `post-deploy` phase, with a default Burrow **derives from what it provisioned** (attached add-on → connect with the app's own `DATABASE_URL` and `SELECT 1`; mounted volume → create, read back, delete; published exposure → request the port) — **derived, not configured**, and the part no generic platform can offer, since a PaaS that did not attach your database cannot test it. It runs **from inside the app's container**, with the app's own environment and credentials, because a check run anywhere else proves the *cluster* can reach the database rather than the *app* — which is exactly where misconfiguration lives. §5 answers the non-technical user through their agent: Burrow states on its surface that an app with no health endpoint is one whose broken deploys look successful, that adding one is usually a few lines, and — critically — that it should check **its own readiness to serve, not its dependencies** (§2, which an agent will otherwise get wrong, since checking the database from `/healthz` is the internet's most common example); the user never learns what a readiness probe is, their agent does. §6 makes every default **fail toward deployed**, because a probe wrongly reporting healthy costs a bad release the user can roll back, while one wrongly reporting unhealthy costs the ability to deploy at all during an incident — and they will disable health checking entirely rather than debug it, losing the feature permanently. Names the costs: an unpublished app gains nothing, running a check inside an image with no shell or `psql` likely needs an injected static probe binary via an init container (real work — and requiring the image to carry tools fails on exactly the minimal images users are told to build), `post-deploy` gains a Burrow-supplied default on a path ADR-0072 described as user-configured, and the agent **will** sometimes add a dependency-checking endpoint anyway. Rejects setting liveness (§1), letting readiness check the database (§2 — the intuitive reading, and a correlated-failure amplifier), defaulting to `/healthz` on 8080 (**a default wrong 20% of the time is not a default, it is a trap**), port scanning (a bound port is not necessarily the serving one), requiring every app to declare an endpoint (makes health checking a precondition of deploying, and an off-the-shelf image cannot comply), continuous dependency monitoring (that is [ADR-0074](0074-burrow-observes-what-it-manages.md)'s ledger, and duplicating it would put one fact in two places with two retention policies), and **Burrow writing the endpoint into the user's code** (it deploys code, it does not author it — the agent authors, Burrow explains why) | Accepted | -| [0077](0077-placement-policy-for-pods-burrow-does-not-author.md) | Placement policy for pods Burrow does not author — [ADR-0073](0073-placement-policy-reaches-every-authored-pod.md) made every pod the engine **authors** reachable through hooks shaped `func(*corev1.PodSpec)`, which works because Burrow builds the spec and can hand it over before writing. [ADR-0066](0066-postgres-on-cloudnativepg.md) breaks that assumption: a CloudNativePG `Cluster` is authored by **the operator**, and CNPG exposes `spec.affinity` and `spec.topologySpreadConstraints`, **not a PodSpec** — so there is nothing to hand to the hook, and the gap lands on the pod ADR-0073 itself calls the platform hook's *"most dangerous reach"*, the one holding tenant data whose placement decides whether its volume can attach. The failure is the quiet kind: a `Cluster` whose pods cannot schedule reports zero ready instances, which reads as a slow start, and nothing says the hook was **skipped** rather than applied and found nothing to do. §1 restates the rule from *every pod the engine authors* to **every pod the engine causes to exist** — whether Burrow composes the spec or hands a custom resource to an operator that composes it is an implementation detail of how Burrow asks for a workload, not a reason the operator's cluster rules stop applying — and makes "can placement policy reach the pods it creates" a criterion for adopting an operator at all. §2 adds a **third seam** rather than widening the second, because the two cannot be the same type: forcing the platform hook to serve both would mean synthesising a fake `PodSpec`, letting the operator mutate it, and scraping the fields back out — **inventing a pod that never exists so a signature can be preserved**, with fields that have no destination vanishing unnoticed. It is shaped as the placement *vocabulary* (node selector, tolerations, affinity, topology spread) rather than CNPG's schema, so a second operator maps onto the same seam without exposing a dependency's API as Burrow's public one. §3 is the load-bearing rule: policy the target cannot express is **refused at wiring time, not dropped at write time** — Burrow refuses to start rather than writing a `Cluster` that silently lacks it, because an operator who wires a hook and is not told it was ignored believes their policy is in force, and **a silently dropped `runtimeClassName` on a database holding tenant data is precisely the failure that must not be quiet**. §4 states the volume bound where a wiring author meets it: under CNPG the operator manages the claims, so pods that cannot reach their volumes are not a scheduling inconvenience but a database that will not start. §5 names the concrete case the seam must express — the cloud's one server-node toleration and deliberately nothing else, since k3s local-path volumes bind to one node and any steering strands them; a design that cannot express *"tolerate this taint, touch nothing else"* has failed. Names the costs: **three seams now**, existing untranslatable wirings can newly block a start-up (the intended direction — a database that refuses to start is recoverable, one silently running unplaced is discovered during an incident — but it must name the field that had no destination), and the translation is a maintenance surface needing a test that fails when **CNPG's** schema moves rather than only when Burrow's code does. Rejects widening the platform hook via a synthesised PodSpec (§2), accepting that operator-authored pods take no placement policy (narrows ADR-0073 §1 by accident, and the next operator inherits the hole without a decision), letting the cluster administrator set CNPG's affinity directly (Burrow owns the `Cluster`, so a hand edit is **reconciled away** — not bypassed but reverted), a mutating admission webhook (ADR-0073's reasons stand; still available *on top of* this rather than instead), and per-operator hooks (exposes a dependency's API shape as Burrow's seam, making a CNPG upgrade a breaking change) | Accepted | +| [0077](0077-placement-policy-for-pods-burrow-does-not-author.md) | Placement policy for pods Burrow does not author — [ADR-0073](0073-placement-policy-reaches-every-authored-pod.md) made every pod the engine **authors** reachable through hooks shaped `func(*corev1.PodSpec)`, which works because Burrow builds the spec and can hand it over before writing. [ADR-0066](0066-postgres-on-cloudnativepg.md) breaks that assumption: a CloudNativePG `Cluster` is authored by **the operator**, and CNPG exposes `spec.affinity` and `spec.topologySpreadConstraints`, **not a PodSpec** — so there is nothing to hand to the hook, and the gap lands on the pod ADR-0073 itself calls the platform hook's *"most dangerous reach"*, the one holding tenant data whose placement decides whether its volume can attach. The failure is the quiet kind: a `Cluster` whose pods cannot schedule reports zero ready instances, which reads as a slow start, and nothing says the hook was **skipped** rather than applied and found nothing to do. §1 restates the rule from *every pod the engine authors* to **every pod the engine causes to exist** — whether Burrow composes the spec or hands a custom resource to an operator that composes it is an implementation detail of how Burrow asks for a workload, not a reason the operator's cluster rules stop applying — and makes "can placement policy reach the pods it creates" a criterion for adopting an operator at all. §2 adds a **third seam** rather than widening the second, because the two cannot be the same type: forcing the platform hook to serve both would mean synthesising a fake `PodSpec`, letting the operator mutate it, and scraping the fields back out — **inventing a pod that never exists so a signature can be preserved**, with fields that have no destination vanishing unnoticed. It is shaped as the placement *vocabulary* (node selector, tolerations, affinity, topology spread) rather than CNPG's schema, so a second operator maps onto the same seam without exposing a dependency's API as Burrow's public one. §3 is the load-bearing rule: policy the target cannot express is **refused at wiring time, not dropped at write time** — Burrow refuses to start rather than writing a `Cluster` that silently lacks it, because an operator who wires a hook and is not told it was ignored believes their policy is in force, and **a silently dropped `runtimeClassName` on a database holding tenant data is precisely the failure that must not be quiet**. §4 states the volume bound where a wiring author meets it: under CNPG the operator manages the claims, so pods that cannot reach their volumes are not a scheduling inconvenience but a database that will not start. §5 names the concrete case the seam must express — the cloud's one server-node toleration and deliberately nothing else, since k3s local-path volumes bind to one node and any steering strands them; a design that cannot express *"tolerate this taint, touch nothing else"* has failed. Names the costs: **three seams now**, existing untranslatable wirings can newly block a start-up (the intended direction — a database that refuses to start is recoverable, one silently running unplaced is discovered during an incident — but it must name the field that had no destination), and the translation is a maintenance surface needing a test that fails when **CNPG's** schema moves rather than only when Burrow's code does. **Refined by [ADR-0100](0100-a-pod-mutator-learns-which-apps-pod-it-is-shaping.md)** (widens the *app* hook only; §2's promise that `WithPlatformPodMutator` keeps its `*corev1.PodSpec` signature still binds). Rejects widening the platform hook via a synthesised PodSpec (§2), accepting that operator-authored pods take no placement policy (narrows ADR-0073 §1 by accident, and the next operator inherits the hole without a decision), letting the cluster administrator set CNPG's affinity directly (Burrow owns the `Cluster`, so a hand edit is **reconciled away** — not bypassed but reverted), a mutating admission webhook (ADR-0073's reasons stand; still available *on top of* this rather than instead), and per-operator hooks (exposes a dependency's API shape as Burrow's seam, making a CNPG upgrade a breaking change) | Accepted | | [0078](0078-the-cli-points-at-a-target.md) | The CLI points at a target, and `burrow auth` is how you choose one — `burrow` decides what to talk to by reading whatever kubeconfig context happens to be current ([ADR-0014](0014-cli-auth-via-kubeconfig.md)), which is unstated anywhere and **cannot describe Burrow Cloud at all**, since a managed tenant owns no cluster. A **target** is where the control plane is, and there are **two kinds**: Burrow Cloud, or any Kubernetes cluster the person has a context for. A kubeconfig target stores the context **NAME and never a copy of the credential**, so rotation and provider-managed kubeconfigs keep working with nothing going stale. `burrow auth login` opens with `gh`'s question — *"Where do you use Burrow?"*, `burrow-cloud.dev` first and default, then `Other` — so someone who came for the managed product reaches it by pressing return and is never shown a Kubernetes concept; `Other` lists kubeconfig contexts by name, and **no kubeconfig means the CLI says so and stops** rather than prompting for a URL. **Authenticating is not installing**: install is once per cluster and needs cluster-admin, auth is per-person and repeatable, so the **second person to use a cluster installs nothing** — and install already mints a scoped context for the AGENT only ([ADR-0038](0038-scoped-agent-credential.md), `cmd/burrow/agentcred.go`), never for the human. The active target is shown by `auth status`, changed by `auth switch`, and **named by every command that changes something**, because the failure this introduces — acting on the wrong target — cannot be designed away (both are legitimate) and so must be made immediately visible. Costs named: the CLI gains targeting state it never had, and the open-source binary carries a command group defaulting to a commercial product (accepted; the alternative forecloses the migration). Rejects **inference** (silently guesses for anyone holding both, and the wrong guess is a deploy landing somewhere unintended), a **`--target` flag everywhere** (taxes every invocation and makes managed worse than self-hosted), a **separate `burrow-cloud` CLI** (forecloses the self-hoster→managed migration this exists to enable), a **third target kind** for a managed control plane on the customer's cluster (no design behind it; a guess embedded in the CLI's core concept), and **installing from `auth login`** (merges a per-person act with a cluster-admin one) | Accepted | | [0079](0079-the-observer-watches-and-latches.md) | The observer watches, and latches a transition before recording it — [ADR-0074](0074-burrow-observes-what-it-manages.md) §3 said **watch**; what shipped sweeps every **60s** (`DefaultObserveInterval`), and `status.unschedulable_grace` already defaults to **30s**, so the ledger cannot resolve a threshold the platform itself applies and lowering that grace silently does nothing. But **a raw watch is worse than a sweep**: Kubernetes status is a stream of edges, most meaning nothing (a pod flaps NotReady/Ready during any rolling update), and recording each would rebuild the event stream §4 rejected — thousands of rows for one problem and a `first_seen` meaning "when the last flap began". So: **watch, and latch on BOTH edges** — a condition must persist for a dwell before opening a row and clear for one before closing it, since latching only the opening edge lets a flapper open and close repeatedly and makes §4's occurrence count count flaps. **Dwell is per reason**, on the principle that *a reason already produced by waiting gets no further wait*: `OOMKilled` and `CrashLoopBackOff` and the deadline reasons get **none** (the kill already happened; the backoff IS the dwell), `Unschedulable` gets the existing grace, image-pull reasons get a short one. Dwells are `status.` limits under [ADR-0068](0068-operational-limits-are-configuration.md), so one setting binds the status surface and the ledger together. **A dropped watch is a gap**: coverage ends on disconnect and resumes on re-list, and a re-list reports current state rather than what was missed. Costs named: determinism gets harder and must not be surrendered (dwell timers on the injected clock, watch substitutable, or the observer becomes the one core component untestable per [ADR-0010](0010-testing-strategy.md)); burrowd holds an object cache plus a pending-transition set; the ledger deliberately **lags the cluster by the dwell**; and a flap that never exceeds its dwell is **invisible** — a real pathology this will not surface. Supersedes ADR-0074 §3's mechanism only. Rejects **shortening the sweep** (cost scales with managed set × frequency, to approximate what a watch reports exactly), **a latchless watch** (reintroduces the event stream and calls it a ledger), **one dwell for all reasons** (wrong in both directions at once), **watch-plus-sweep-for-§6** (tempting and out of scope — it would decide §6's mechanism as a side effect of deciding §3's), and **debouncing at read time** (stores the noise to hide it later, against §4's bounded retention) | Accepted | | [0080](0080-a-rollback-is-not-blocked-by-its-own-hook.md) | A rollback is not blocked by its own hook — [ADR-0072](0072-deploy-and-run-lifecycle-hooks.md) §3 aborts a deploy on a failed `pre-deploy`; §8 decides when `pre-rollback` runs and is **silent on its failure**, so the implementation made it symmetric and aborts the rollback (`6c1bd07`). Right reading, and it collapses **two different failures into one abort**: the migration revert failed (abort is correct — old code against a half-reverted schema is what the ordering prevents) versus **the hook could not run at all** (image will not pull, Job unschedulable, taint on the node pool, typo, wedged past the bound) where the schema is fine and the rollback is blocked by something unrelated. The second arrives at the worst moment, because **rollback is the incident escape hatch** — and today the only way past it is `hook unset`, which **deletes the hook**, so recovering costs configuration under pressure and the next rollback runs with no schema protection at all: the failure this was built to prevent, reached through its own escape. So the **abort stays** (§1, the default is safe for anyone who has not thought about it), `rollback` gains **`--skip-hooks`** and nothing else does (§2 — a deploy can wait while a hook is fixed; the same flag on `deploy` would be a way to routinely skip migrations, a different feature), and it is **non-destructive**: the hook stays configured, one invocation ignores it. **Operator-only** (§3): deciding a safety step does not apply *in this situation* is exactly the judgement [ADR-0065](0065-what-belongs-on-the-agent-surface.md) keeps off the agent surface, but the refusal **names the flag and that a human runs it**, per §7's rule that an absent-and-legible capability is a refusal the agent can relay while a dead end pushes it off the channel entirely. **Skipping is audited** (§4) with which hook, app and environment — "we rolled back around a broken hook" explains why the schema looks how it does, and is what nobody writes down at three in the morning. Costs named: a safety step becomes skippable and somebody will skip one whose failure *was* the revert; the two failure modes still **look identical at the moment of decision** (the `HookError` carries phase, command, exit code and bounded output — making the distinction legible is worth doing and is not decided here); and it is one more flag to remember on the incident path. Rejects **making a failed `pre-rollback` non-blocking** (removes the protection in every case including the one that matters; a hook that cannot stop anything is a log line), **keeping `hook unset` as the only escape** (the lossiness IS the defect), **a broad `--force`** (bundles this with whatever such a flag later accumulates), **letting the agent skip when it can show the failure was infrastructural** (puts the agent in the position of judging when a safety step does not apply, and the case where it is wrong is the case where the schema really was half-reverted), and **a confirmation prompt** (no one to answer it in a script, a runbook, or CI — which is how a 3am rollback is often run) | Accepted | @@ -188,3 +188,4 @@ so they are listed here: | [0097](0097-guardrails-hold-the-agent-and-nobody-else.md) | Guardrails hold the agent, and nobody else — a guardrail exists to stop an agent going off the rails, and it never applied to people in any useful sense: a person's **Kubernetes RBAC is the ceiling** on what they can do, so anything Burrow refuses them they can do with `kubectl` a second later. So a disposition binds the **agent** and nothing else — `user` and `machine` credentials are allowed everything, always, with no confirmation — and **`--binds` is removed from `guard set`**, because it made every operator answer a question with one correct answer and got it wrong by omission: a `deny` written without it silently froze the human too, which is the exact defect the flag was introduced to fix. Credential **kinds stay**, because the server has to know who is asking for "hold the agent" to be expressible at all, and because the audit trail already names them. A **machine is not an agent** (it runs a reviewed script rather than choosing its own actions; an operator who disagrees issues CI an agent credential), an **undispositioned code still denies the agent** and allows everyone else (the fail-safe stays where it matters and costs a person nothing), and **team-level restriction is Kubernetes' job** — the natural next step once three kinds exist, and the reason to write this down now, since a Burrow-only restriction binds one path and reads as protection while `kubectl` sits open beside it. Consequences named: a change freeze no longer freezes people (a freeze a person can step around with `kubectl` was never a freeze), `confirm` becomes agent-only in name as it already was in effect, and an install that used `--binds` to bind a human changes behaviour. Rejects **keeping `--binds` defaulted to `agent`** (the rare case is not real, and every operator would still have to learn the flag to decide they do not want it), **binding `machine` only**, **per-person guardrails**, and **leaving it alone** (correctness that depends on remembering a flag is not correctness — the failure mode is silent and lands on the operator trying to make things safer) | ✅ Accepted | | [0098](0098-a-config-write-is-guarded.md) | A config write is guarded, because it rolls the app — setting or removing an app's config var **re-applies the running workload**, so the app restarts and comes back with an environment somebody changed, and no guardrail covered it: the control plane's own comment gave the reason as *"config vars are non-secret"*, which describes the value and not what writing it does. It was the one mutating app verb with **no disposition of any kind** — an agent could do it freely, and `burrow guard set app.config … deny` failed with `unknown guardrail`, which is how it surfaced: the managed control plane runs as an ordinary Burrow app, its configuration carries a service-account namespace **checked against the cluster credential at startup**, and one agent config write rolls it into a state it does not come back from. So: **one code, `app.config`, covering set and unset**, defaulting to **`confirm`** — the same call `app.run` gets, because the change is arbitrary, the app rolls, and the confirmation *can* be an informed one ("set `DATABASE_HOST` on `web` in prod, which rolls the app" is a sentence a human can act on). It is **env-scopable and app-scopable**, so the shape an operator wants is expressible: `guard set --env dev app.config allow` for a sandbox, `guard set --env prod --name burrowd-cloud app.config deny` for the one app that cannot survive a roll. **`--no-restart` does not skip it** (the value still lands in the store and the next deploy carries it, and `app.deploy` is allowed by default), and the decision plus the execution reach the audit trail as `config_set` / `config_unset`, recording key names and **never values**. Rejected: **leaving it ungated** (secrecy is not the question the guardrail asks), **two codes** (splitting set from unset protects an app against half an operation), **default `allow`** (protects only the operators who already knew), **default `deny`** (sends the ordinary loop through a human every time, whose reasonable answer is a global relax), **gating only the rolling form** (`--no-restart` then becomes the way around it), **gating on which key is written** (which variable takes an app down is a fact about the app's code, and a permissive classifier is a gate people trust and should not), and **folding it into `app.deploy`** (freezing releases and holding configuration are different statements) | ✅ Accepted | | [0099](0099-an-agent-may-not-rewrite-its-own-limits.md) | An agent may not rewrite its own limits — a guardrail holds the agent, and **the agent can turn it off**. Two independent doors, both open today: `PUT /v1/guard/{code}` performs **no caller check** in either the handler or the engine, so any credential that authenticates can relax the table that holds it; and the **admin bit is a property of the principal, not the credential**, so an admin's AGENT credential can create an invitation, redeem it, and hold a `user` credential for which every disposition resolves `allow`. So an agent has two routes out — change the rule, or change what it is — and neither needs the other. It is worse than when first raised, because a person can now hold a Burrow credential with no cluster access beyond a proxy to one Service, so the confused deputy fits a **two-person self-hosted install** rather than needing a shared enterprise cluster. **Both close the same way**: a credential of kind `agent` may not write policy and may not mint identity; reading the policy stays open, since an agent that can see what binds it can explain a refusal to its person. An **unknown kind is treated as an agent here as everywhere else**, because on a shared-token install nobody has a kind — including the agent — and reading unknown as a person would leave both doors open on exactly the installs that have only an agent to hold. Consequence named: an operator on a shared-token install must authenticate before changing policy, so `burrow guard set` from a machine that never ran `burrow auth login` starts refusing. **The managed product is unaffected** — the policy write is absent for tenants and the identity routes are inert. Two comments in the tree currently assert the binding holds "by construction" and are corrected, since they are the reason this went unnoticed. Rejects **requiring `--confirm` on a policy write** (a confirmation is satisfied by the caller, so the agent would pass the flag — a hold the held party can satisfy is not a hold), **making it a guardrail with its own disposition** (circular: the agent relaxes `guard.set` and proceeds), **checking the admin bit rather than the kind** (an admin's agent carries the admin bit, so an admin's agent keeps both doors), and **leaving it documented** (it makes every guardrail on a self-hosted install decorative). Corrects an omission in [0097](0097-guardrails-hold-the-agent-and-nobody-else.md), which decided what a disposition binds without saying who may change one | ✅ Accepted | +| [0100](0100-a-pod-mutator-learns-which-apps-pod-it-is-shaping.md) | A pod mutator learns which app's pod it is shaping — [ADR-0061](0061-deploy-pod-mutator-seam.md) §1's hook takes a bare `func(*corev1.PodSpec)` and is wired **once for the whole process**, so an operator can shape every app's pod or none, and **cannot shape one app's differently from another's**. The concrete need is choosing a sandboxing runtime per app rather than per install, so one app moves onto a stronger isolation boundary while its neighbours stay put. The engine already holds the answer and throws it away: both invocation sites sit inside functions whose argument carries the app (`buildDeployment` has a `WorkloadSpec`, `runJob` a `RunSpec`, both with an `App` field used a few lines earlier for labels and the container name), and the adapter's namespace is in scope at both — **nothing needs threading anywhere**. §1 adds `WithAppPodMutator(func(PodIdentity, *corev1.PodSpec))`; §3 keeps `WithPodMutator` at its **exact** signature as a `Deprecated:` wrapper storing into the **same field**, so there is one mechanism, one invocation per site, no precedence question, **nothing breaks**, and ADR-0061 §3's byte-for-byte guarantee is untouched. §2 is the load-bearing constraint: `PodIdentity` carries `App`, `Namespace` and whether this is the long-running workload or a one-off run — **identity, never a key into an embedder's records**. No tenant, no environment name, no account. The only embedder keys its per-app rows on a tuple this repository has no concept of, and widening the hook with the fields that consumer needs today would **encode one consumer's storage layout in a public API**, making every re-keying on their side a breaking change on this one; `Namespace` qualifies because it is a fact about the cluster, and an embedder resolves it against its own records. §4 leaves `WithPlatformPodMutator` alone — its pods (add-on instance, log and metrics collectors, backup and restore Jobs) **belong to no app**, so one shared identity type would have an empty `App` at half its call sites, and an identity absent exactly where a reader looks for it is worse than none. §5 keeps the compile-time signature pin that fires on this very change, and adds a third line for the new method, because a pin whose comment describes a world that no longer exists is one the next reader distrusts and then weakens. Costs named: **two spellings of one seam** (ADR-0073's two-hook argument was about two pod *sets*; these cover the same set and differ only in what they are handed), and an embedder that can treat two apps differently can now do so **by mistake**, landing on a pod template rather than at a compile error. Rejects **changing the signature outright with a renamed shim** (the same decision with the names swapped — breaks every embedder to buy only a name, and requires editing the guard that exists to catch this), **a per-app `Adapter` view** (`WithNamespace` already shows the failure: it returns the **receiver**, not a copy, when the namespace already matches — the default-environment path, so the "view" is the shared object and the wrong app's pod is shaped with no error; `Adapter` is not safely shallow-copied either, its controller placement holding a map, a slice and a pointer), **letting the hook re-derive the app from a label or image** (ADR-0073 §2's rejected shape), **handing over the whole `WorkloadSpec`** (the two sites share no type, so the run path would need a synthetic one — ADR-0077 §2's *"invents a pod that never exists"* — and it would expose the app's env and secret keys for no gain), and **carrying the environment name** (neither spec has one; threading it through the engine to serve a consumer's storage key is §2 exactly) | ✅ Accepted |