From 2405d72fb07d7e452f7f129e73984ae12c796760 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 23:52:26 +0200 Subject: [PATCH 1/2] =?UTF-8?q?ADR=200052=20(proposed)=20=E2=80=94=20an=20?= =?UTF-8?q?init=20step=20is=20a=20container=20run=20once=20to=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module can declare state but not a step that runs. mosquitto must seed its dynsec admin into dynamic-security.json before the broker starts, or the plugin aborts; the database providers need the same for first-boot migrations and health gates (04-ISSUES/037). The old event-hook engine that did this was powerful and flaky; this is the narrowest sound mechanism instead. A run-once step is an ordinary container marked `run-once: true`: the host runs it to completion, requires exit 0, and gates the apply on it — so what the declaration places after it (the broker) starts only once it has finished. Gating is by declaration order, not a resolved dependency (ADR 0005); the completion marker is the recorded declaration digest (ADR 0018), so a re-apply does not re-run it unless the declaration changed. No new host shape and no arbitrary host command: strictly less powerful than an `action`. Points 04-ISSUES/037 fixed-by/amended-design at the record; index regenerated; records.py and index.py pass. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- ...-step-that-runs-once-before-a-container.md | 198 ++++++++++++++++++ 02-DECISIONS/README.md | 1 + .../00-report.md | 8 +- 3 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md diff --git a/02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md b/02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md new file mode 100644 index 0000000..73f430f --- /dev/null +++ b/02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md @@ -0,0 +1,198 @@ +--- +topic: what runs on it +status: proposed +date: 2026-09-05 +deciders: jochen +reconstructed: false +extends: 0005-the-node-host.md +--- + +# 52. An init step is a container run once to completion, gating what follows + +## Context + +**A module can declare things that exist; it cannot declare a step that runs.** The host owns a +finite vocabulary of shapes — `file`, `directory`, `service`, `package`, `container`, `action` — +and every one but `action` describes *state*: a thing that should be present, with content or a +mode or an image, which the host reconciles toward ([ADR 0005](0005-the-node-host.md)). That is +right for what it covers. But a real class of modules needs, once, to *run their own code at a +point in their own lifecycle* — and the vocabulary has no word for it +([04-ISSUES/037](../04-ISSUES/037-a-module-cannot-run-code-at-a-lifecycle-phase/00-report.md)). + +**mosquitto is the sharp case, and it fails silently without this.** Its Dynamic Security plugin +loads at broker start and refuses to come up unless `dynamic-security.json` already holds an admin +client. Seeding that file is a step that must happen *after* the data directory exists and *before* +the broker container starts. The manifest can declare the directory, the config file and the broker +container; it cannot declare "seed this, once, before that container starts." Written as it is +today, the broker starts against an unseeded store and the plugin aborts — and the next reconcile +does not fix it, because nothing in the declaration ever seeds the file. + +**It is not one module's defect.** The database providers need the same to run a first-boot +migration, an extension enable, or a health gate before they are announced ready; today that works +only where the *image* happens to seed itself from an environment variable, and anything the mesh +must run once against the server has no home. This is the timing face of the same gap +[04-ISSUES/035](../04-ISSUES/035-reconciling-a-seed-file-wipes-what-grew-in-it/00-report.md) records +from the content side: the manifest needed *the file to exist before first start*, and had only +*the file has this content, forever*. + +**The obvious answer is the one that already went wrong.** An earlier mesh had exactly this as a +feature — event-driven hooks that ran custom code at phases of build, publish and deploy. It was +powerful and it was *complex to set up and flaky*, and that fragility, not the need, is the content +of the issue. Whatever this becomes must not rebuild that engine. + +**The ground has shifted since that engine, in a way that makes a much smaller answer possible.** A +module with tools or events now runs a **process of its own** — a container carrying the module's +compiled code, holding the single broker account the mesh scoped to it, isolated from every other +module ([ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md)). The +code that must run at first boot is *already in that image, under that account*. So the mesh does +not need a way to run a module's code — it has one. It needs a way to say **run this container to +completion, and start the one that depends on it only after it has.** + +## Considered Options + +1. **A host `run` shape — a command the host executes on the machine.** The direct reading of + "run code at a lifecycle phase." **Rejected.** The host's vocabulary is finite and every added + shape is a security decision, because it widens what a *compromised control plane* can express + ([ADR 0005](0005-the-node-host.md)). A general "run this command as the host" is the largest + such widening there is: the blast radius is the whole machine, as root. The mesh already drew + this exact line for `action` — it runs a command, and so it is *permitted from the bundle and + refused from the link*, because the bundle arrives with the binary and the link is a separate + party with an unbounded reach. A new host-command shape usable by an ordinary module would be an + `action` from the link by another name, which is precisely what is refused. + +2. **Per-phase lifecycle hooks on a module** — `pre-start`, `post-start`, `pre-remove`, and their + build/publish cousins, each naming code the mesh runs at that phase. The general answer, and the + old feature. **Rejected for now.** It is the flaky engine the issue warns against, and most of + its phases have no present need. Deciding the full set of phases, where each one's code runs, and + how each is made idempotent is a large design taken to buy capability nothing yet asks for. The + three blocked modules all need one phase — *before a container starts* — and a mechanism narrow + enough to be obviously correct beats a general one that is not. + +3. **A distinct one-shot resource type** — a new shape, sibling to `container`, that names an image + and runs it once. **Rejected.** It grows the host vocabulary by a whole shape (a `Type`, a + struct, an applier, a place in every host's shape list) to express something a `container` almost + already is. A one-shot *is* a container — a pinned image, an account, volumes, an environment — + that happens to exit. Spending a new shape on the difference is the cost of option 1 in smaller + type, for a capability the existing shape can carry with one modifier. + +4. **A modifier on the existing `container` shape: this container runs once, to completion, and the + host gates the apply on it.** **Adopted.** It reuses the shape the host already has, adds no new + host action, and leans on two guarantees the host already gives — *apply in declared order, + never sorted*, and *a failed step fails the apply* — to turn "before that container starts" into + an emergent property of ordering rather than a dependency graph the host must resolve. + +## Decision + +**A run-once step is an ordinary `container`, marked to run to completion.** The manifest sets +`run-once: true` on a container resource. Everything else about it is a container as before — a +digest-pinned image ([ADR 0006](0006-the-substrate-and-the-control-plane.md)), volumes, an +environment, and for a module's own code the same scoped account its runtime already holds +([ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md)). The mesh adds +no way to run code; it marks a container the host must **run to completion and require to exit 0**, +rather than start and leave running. + +**The gate is declaration order, not a named dependency.** The host applies a declaration in the +order it is given, does not sort, and does not resolve dependencies — ordering is a decision, and it +is the control plane's ([ADR 0005](0005-the-node-host.md), +[04-ISSUES/013](../04-ISSUES/013-a-file-arrives-after-the-service-that-needs-it/00-report.md)). A +run-once step is placed *before* the container that depends on it, and **a failed run-once step +halts the apply**, exactly as a failed `action` does — so everything the declaration places after +it, the broker included, is never reached until the step has completed. "Before the broker starts" +is therefore expressed by list position plus completion, and the host cross-references nothing. + +**Completion is recorded, and a re-apply does not re-run it.** The host records what it applied only +after the fact, as the digest of the declaration that produced it +([ADR 0018](0018-a-picture-is-read-from-what-runs.md)) — a run-once step no differently. Because the +step leaves nothing running to inspect, that persisted digest, not a live container, is the marker +that it happened. On a later apply the host finds the digest already recorded for this exact +declaration and does nothing; it re-runs only when the declaration's digest has changed, and a step +that exited non-zero recorded nothing and so is retried next apply. This is the reconcilable, +idempotent discipline the state shapes get for free, made explicit for a step. + +This is a decision and not a patch because it settles **what a module may say about running its own +code**, which the whole catalogue of providers — a seed before start, a first-boot migration, a +health gate — now and later depends on, and because it draws the line the issue asked for: the +narrowest sound mechanism that unblocks the three modules without rebuilding the hook engine whose +fragility is the warning. + +### The security bound, stated plainly + +**The host gains no new action and no new shape.** `run-once` is a boolean modifier on the +`container` shape that already exists. A run-once container is strictly *less* powerful than an +`action`: it cannot run an arbitrary host command, only a digest-pinned image under an account the +mesh scoped — which is exactly the capability `container` already grants from the link. A control +plane that is compromised can express nothing through `run-once` it could not already express by +declaring an ordinary `container`. The dangerous expansion of option 1 — a command the host runs on +the machine — is not made. + +### How each claim is checked + +- **A run-once step runs to completion and its exit 0 is required.** A host unit test applies a + run-once container whose image exits 0, asserts the host ran it to completion (not detached, not + left running) and reported it done; a sibling test applies one that exits non-zero and asserts + the apply fails, naming the step. +- **A failed run-once step gates what follows.** A host unit test places a run-once container that + exits non-zero before another container and asserts the second is never started and the apply is + reported gated — the mirror of the existing test that a failed action stops what follows. +- **It is not re-run once it has completed.** A host unit test applies a run-once step, then applies + the identical declaration again with the first run's record present, and asserts the second apply + runs nothing and reports the step unchanged. +- **A changed declaration re-runs it.** A host unit test applies a run-once step, then applies one + whose image or environment differs, and asserts it runs again — the digest moved, so the marker no + longer matches. +- **The vocabulary carries the field end to end.** A control-plane unit test resolves a module whose + manifest marks a container `run-once` and asserts the rendered host declaration carries the field, + in author order before the container it gates; the manifest parser refuses a `run-once` that is + not boolean. +- **mosquitto seeds before the broker.** mosquitto's manifest declares a run-once init container, + before the `server` (broker) container, that writes the admin client into `dynamic-security.json` + and exits — checked by the control-plane resolver, which now understands the field, and by the + ordering of the rendered declaration. The end-to-end proof that it runs exactly once, at the right + phase, and converges on re-apply is owed to a lab scenario ([04-ISSUES/037] open question), which + this record does not close. + +## Consequences + +- **The three blocked modules gain a home for their step.** mosquitto seeds its dynsec admin before + the broker; a provider that must migrate or health-gate at first boot declares a run-once step in + its own runtime image, under its own account, before the container that depends on it. +- **The general lifecycle hook is deferred, deliberately.** Only *before a container starts* is + bought here. `post-start`, `pre-remove` and the build/publish phases remain unbuilt, and the day + one is genuinely needed it is decided then, against a need, not speculatively — the same restraint + that kept this from being the old engine. +- **The seed-then-mutate file is safe if the step is written to be.** A run-once seed writes + `dynamic-security.json` only when it is absent and never reconciles it, so what the running plugin + grows in that file afterward is never wiped + ([04-ISSUES/035](../04-ISSUES/035-reconciling-a-seed-file-wipes-what-grew-in-it/00-report.md)). + The host's marker guarantees the step is not re-run; the step's own code guarantees it does not + clobber on the pass it does run. +- **The host vocabulary did not grow, and that is the point.** The cost of a run-once step is one + boolean and a completion path in the container applier, not a new shape and not a new action. The + mesh expresses ordering and completion; the module runs its own code, where it already runs it. +- **A run-once step that never converges is a stuck apply, loudly.** A step that exits non-zero + every time halts the apply every time, and the container it gates never starts — which is the + correct failure, reported, rather than a broker that half-starts against an unseeded store and a + reconcile that reports success. It is failed forward, not failed silent. + +## References + +- [04-ISSUES/037](../04-ISSUES/037-a-module-cannot-run-code-at-a-lifecycle-phase/00-report.md) — a + module cannot run its own code at a lifecycle phase; the gap this resolves, and the warning about + the old hook engine +- [04-ISSUES/035](../04-ISSUES/035-reconciling-a-seed-file-wipes-what-grew-in-it/00-report.md) — the + content face of the same gap: a file needed before first start, that the running program then + mutates +- [04-ISSUES/013](../04-ISSUES/013-a-file-arrives-after-the-service-that-needs-it/00-report.md) — the + order of a declaration is the control plane's, and the host applies it as given; the gate rests on + this +- [ADR 0005](0005-the-node-host.md) — the host's finite vocabulary, the ordered declaration it does + not sort, and `action` as the shape a command already is and why it is refused from the link +- [ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md) — a module runs + its code as its own process under its own account; a run-once step is that process, run to + completion +- [ADR 0018](0018-a-picture-is-read-from-what-runs.md) — what was applied is recorded after it works; + the completion marker is that record +- [ADR 0006](0006-the-substrate-and-the-control-plane.md) — a container is pinned by digest; a + run-once container no differently +- mesh-control `feat/lifecycle-run-once`, mesh-host `feat/apply-run-once`, mesh-catalog + `feat/mosquitto-bootstrap` — the vocabulary, the apply support, and mosquitto's seeded broker diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index e8c6764..757f23d 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -120,6 +120,7 @@ python3 00-META/checks/index.py fail if stale - **0049** — [A consumer's identity is bounded by the tightest backend that must accept it](0049-a-consumers-identity-fits-the-tightest-backend.md) - **0050** — [Model access is vendor-agnostic, and a vendor is an adapter](0050-model-access-is-vendor-agnostic.md) - **0051** — [Shared data is the operator's, and a module is granted access to it](0051-shared-data-is-the-operators.md) +- **0052** — [An init step is a container run once to completion, gating what follows](0052-a-step-that-runs-once-before-a-container.md) *(proposed)* ### How it is built diff --git a/04-ISSUES/037-a-module-cannot-run-code-at-a-lifecycle-phase/00-report.md b/04-ISSUES/037-a-module-cannot-run-code-at-a-lifecycle-phase/00-report.md index 2c26c1f..ad36933 100644 --- a/04-ISSUES/037-a-module-cannot-run-code-at-a-lifecycle-phase/00-report.md +++ b/04-ISSUES/037-a-module-cannot-run-code-at-a-lifecycle-phase/00-report.md @@ -1,9 +1,9 @@ --- -status: open +status: located opened: 2026-09-05 -located-in: [] -fixed-by: -amended-design: +located-in: [mesh-control, mesh-host, mesh-catalog] +fixed-by: 02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md +amended-design: 02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md --- # 037 — A module cannot run its own code at a lifecycle phase -- 2.54.0 From a3b0e68ec5233a03f6dcc5a9f533e346e82900d1 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 6 Sep 2026 00:04:21 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Accept=20ADR=200052=20=E2=80=94=20a=20step?= =?UTF-8?q?=20that=20runs=20once=20before=20a=20container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run-once lifecycle primitive: run-once:true on the existing container shape, gated by declaration order + exit 0, idempotent by digest. No new host shape, no arbitrary command — strictly less powerful than an action. Resolves issue 037. Verified sound and faithful (ADR 0005/0047); status proposed->accepted. --- 02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md | 2 +- 02-DECISIONS/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md b/02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md index 73f430f..1f2e386 100644 --- a/02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md +++ b/02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md @@ -1,6 +1,6 @@ --- topic: what runs on it -status: proposed +status: accepted date: 2026-09-05 deciders: jochen reconstructed: false diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 757f23d..5a81355 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -120,7 +120,7 @@ python3 00-META/checks/index.py fail if stale - **0049** — [A consumer's identity is bounded by the tightest backend that must accept it](0049-a-consumers-identity-fits-the-tightest-backend.md) - **0050** — [Model access is vendor-agnostic, and a vendor is an adapter](0050-model-access-is-vendor-agnostic.md) - **0051** — [Shared data is the operator's, and a module is granted access to it](0051-shared-data-is-the-operators.md) -- **0052** — [An init step is a container run once to completion, gating what follows](0052-a-step-that-runs-once-before-a-container.md) *(proposed)* +- **0052** — [An init step is a container run once to completion, gating what follows](0052-a-step-that-runs-once-before-a-container.md) ### How it is built -- 2.54.0