Merge pull request 'ADR 0052 — a step that runs once before a container (issue 037 / run-once primitive)' (#27) from feat/adr-0052-run-once-lifecycle into main

This commit was merged in pull request #27.
This commit is contained in:
2026-09-06 00:05:04 +02:00
3 changed files with 203 additions and 4 deletions
@@ -0,0 +1,198 @@
---
topic: what runs on it
status: accepted
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
+1
View File
@@ -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)
### How it is built
@@ -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