ADR 0135 supersedes 0133: a module version prepares its state before it runs

Two faults in 0133, both caught on review. It put the declaration on a container — one resource kind
the host applies — so every author would restate the machine's arrangement and a module's own
lifecycle would be tied to how its artifact happens to run. A module declares entrypoints for its
tools and its provisioner; preparing its state is the same vocabulary and nothing about a runtime.

And it derived the scope from the machine, which the facts already answer: a consumer is a module on
a machine (issue 022, migration 0015), so what the mesh provisions is per consumer. A module on three
machines has three databases, there is no shared state to race over, and the lock obligation 0133
invented was for a situation the mesh does not produce. The level question HAL answered with stages
dissolves — the scope of preparation is the scope of the state, and the mesh knows it.

0133 keeps its reasoning and gains a pointer; design 32 and issue 133 name the live record.
This commit is contained in:
2026-09-28 11:57:16 +02:00
parent 891c7a945e
commit 476cda417d
5 changed files with 175 additions and 17 deletions
@@ -1,10 +1,11 @@
---
topic: what runs on it
status: accepted
status: superseded
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md
superseded-by: 02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md
---
# 133. A module owns its migrations, and the mesh owns when they run
@@ -0,0 +1,153 @@
---
topic: what runs on it
status: accepted
date: 2026-09-28
deciders: jochen
reconstructed: false
supersedes: 0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md
---
# 135. A module version prepares its state before it runs
## Context
[ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) settled who runs a
module's migrations and when, and it said so in the wrong vocabulary. It put the declaration on a
*container* — "a container may declare steps to run before it" — and derived the scope of the work from
the *machine*. Both are wrong at the level a module author works at, and the second is wrong on the
facts.
**A container is one resource kind the host applies.** A module has code, state and a version; whether
its artifact is an image, a bundle or something later is the mesh's business. The module-facing
vocabulary for a module's own code already exists and has nothing to do with a container runtime: a
module declares **entrypoints** — this file is my tools, this file is my provisioner — and the mesh runs
them. A manifest that says "run this container with these arguments, and here are the volumes and
environment again" has an author writing down the machine's business twice.
**And the scope is not the machine's to decide, because the mesh already decided what a state is.** A
consumer is a module *on a machine* (migration 0015, from
[issue 022](../04-ISSUES/022-one-credential-per-node-per-provision-not-per-module/00-report.md)):
the mesh derives a login per consumer and the provider creates a database owned by exactly that login
([ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)). So a module on three machines is three
consumers, three credentials and three databases. There is no shared state for two machines to race over,
and ADR 0133's central caveat — that a module's migrations must take a lock because two machines might
migrate at once — describes a situation the mesh does not currently produce.
That correction makes the whole "level" question HAL answered with stages disappear: the scope of
preparation is the scope of the state, and the mesh knows it.
What the earlier record got right and this one keeps: the module owns the work, the mesh owns the moment,
the gate is the guarantee, migrations stay forward-only, and none of it can be inferred from inside an
artifact. What produced it also stands — the control plane was replaced with a build carrying a migration,
nothing applied it, and for three quarters of an hour every build was refused by the store with one line
that reached only whoever was waiting on a reply
([issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md)).
## Decision
**A module version declares an entrypoint that prepares its state.** One name in the manifest, in the
same vocabulary as the entrypoints it already declares for its tools and its provisioner. No container,
no command line, no environment, no mounts — those are how a machine runs the module's code, and the
module already said that once.
**The mesh runs it as it runs that module's own code, to completion, in the module's own context.** Every
binding, credential and setting the module's code would receive, because it *is* the module's code. How a
machine does that is the host's business and stays there: for an image artifact it is the step
[ADR 0052](0052-a-step-that-runs-once-before-a-container.md) already defines, and a later kind of artifact
changes the host, not the manifest.
**Preparation gates the version.** A version whose preparation did not succeed does not run — anywhere.
Since the rollout already sends machines one at a time and stops at the first that does not take a
version, a preparation that fails stops the rollout there, leaving every other machine on the version
that works.
**Preparation is scoped to the state, and the mesh derives that scope.** State the mesh provisions is per
consumer — a module on a machine — so preparation happens once per consumer. State the module keeps on
the machine is per machine, which is the same answer. A module that holds an exclusive seat has one of
itself, so its preparation happens once by definition. No level, no election, no cross-node ordering, and
no lock obligation invented for a race the mesh does not create.
**Once per version per state.** A version bump attempts preparation once against each state it has; the
module's own runner decides there is nothing to do, which is what a runner with a version table does
anyway. A retry after a partial failure runs it again, so the work is the module's to make safe against
that — the one obligation no design can remove.
**Forward-only and additive.** Preparation runs while the previous version is still serving, so a
migration that removes or renames what the old code reads breaks the mesh in the window between the two.
**Declared, never inferred.** The control plane cannot see inside an artifact, so a module that ships
migrations and declares no entrypoint is not refusable at registration. It breaks on its first upgrade,
and this record says so rather than implying a check that cannot exist.
## Options considered
1. **A container declares steps before it** — [ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md).
Superseded, not because the mechanism is wrong but because the *declaration* is in the wrong place: it
makes every module author restate the machine's arrangement, and it ties a module's own lifecycle to
one resource kind. The host-side mechanism it named is retained and is now an implementation detail.
2. **Each module prepares itself when it starts** — what the catalogue does today. Rejected: a schema
failure becomes a crash loop rather than a stop, nothing in the declaration says the module has a
state to prepare, and the version serves the moment it starts rather than after the state is right.
3. **The mesh applies migrations itself**, with a driver and a version table per store type. Rejected:
the mesh would have to know one store from another, hold another module's credentials and reach a
machine with them, which [ADR 0005](0005-the-node-host.md) forbids. It is also what forces a stage
system: something central has to decide where the work happens.
4. **A hook lifecycle** — pre-build, post-build, pre-deploy, post-deploy. Rejected: a declaration is a
desired state reconciled forever, so there is no deploy moment to hook. "Pre-deploy" is exactly this
record; pre- and post-build are what a recipe and the artifact list already are; "post-deploy" names
nothing that happens.
5. **A declared level** — once per module, or once per assignment. Rejected: the mesh already knows what a
state is, so asking an author to choose is asking them to restate a fact the mesh holds, with a chance
of contradicting it.
6. **Record a preparation level per module in the store.** Rejected for the reason ADR 0133 gave and this
record keeps: gating makes the invariant true by construction, and a level is a second account of the
same fact.
## Consequences
**An author's whole contract is one line, once.** Write the migration in the module's code, name the
entrypoint that runs it, and every later version rolls out as: build, prepare, run — with nothing
per-version to remember and nothing about the machine to restate. That is the property this exists for.
**Three hand-written steps in the catalogue collapse**, and the control plane's own migrate step stops
repeating its server's environment and mounts.
**The catalogue's self-preparation becomes the exception to remove.** One shape, and the mesh's own
control plane is not an exception either.
**A module scaled across machines with one shared state is not expressible**, and this record does not
make it so. The mesh gives each consumer its own state; a deliberately shared one is a different
provision model, and the place the "once, mesh-wide" question would genuinely return. Named here so it is
a decision when it happens rather than a surprise.
**There is still no readiness-gated step.** Only an action carries `verify`; nothing declares that a
service answers, so preparation that must happen *after* something is serving — seeding through its own
API — remains unexpressible.
**Genesis keeps its own action.** At birth there is no control plane to derive anything, which is what
[ADR 0067](0067-genesis-is-a-pivot.md) says about that moment.
## How this is checked
- **The composition carries the preparation, in the module's own context.** A test on a node's composed
declaration: a version declaring a preparation entrypoint is preceded by it, and what it is given
equals what the module's own code is given — asserted equal rather than written twice, which is the
drift the superseded shape invited.
- **A preparation that fails stops the version.** The host does not go past a step that did not complete,
and the rollout stops at the first machine that did not take a version. Both are existing behaviours
with existing tests; the test for preparation asserts the two together — the machine does not run it,
and the machines after it are left alone.
- **Once per version per state.** A test that a second convergence of the same version prepares nothing,
and that a new version prepares again.
- **The mesh's own control plane declares one.** The case that failed on 2026-09-28 is the case the tests
cover, rather than a case a comment says is covered.
## References
- [ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) — what this supersedes, and why
- [ADR 0052](0052-a-step-that-runs-once-before-a-container.md) — the host-side step that implements it for an image artifact
- [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md), [issue 022](../04-ISSUES/022-one-credential-per-node-per-provision-not-per-module/00-report.md) — a consumer is a module on a machine, which is what makes the scope derivable
- [ADR 0018](0018-a-picture-is-read-from-what-runs.md) — a digest is the record that something happened
- [ADR 0005](0005-the-node-host.md) — the control plane decides and never touches a machine
- [ADR 0134](0134-the-mesh-says-what-it-applied.md) — what makes a failed preparation visible
- [issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md) — the failure that produced both records
+2 -1
View File
@@ -214,7 +214,8 @@ python3 00-META/checks/index.py fail if stale
- **0120** — [A roster fact carries its format as a template: the mesh owns the data, the module owns the format](0120-a-roster-fact-carries-its-format-as-a-template.md)
- **0121** — [A system seat is named for its scope, and a module may define its own](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)
- **0122** — [A seat is data the controller owns, and a rename is a database update](0122-a-seat-is-data-a-rename-is-a-database-update.md)
- **0133** — [A module owns its migrations, and the mesh owns when they run](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md)
- **0133** — [A module owns its migrations, and the mesh owns when they run](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) *(superseded)*
- **0135** — [A module version prepares its state before it runs](0135-a-module-version-prepares-its-state-before-it-runs.md)
### How it is built