Files
hq/02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md
T
jschoubben 476cda417d 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.
2026-09-28 11:57:16 +02:00

10 KiB

topic, status, date, deciders, reconstructed, supersedes
topic status date deciders reconstructed supersedes
what runs on it accepted 2026-09-28 jochen false 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 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): the mesh derives a login per consumer and the provider creates a database owned by exactly that login (ADR 0048). 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).

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 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. 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 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 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 — what this supersedes, and why
  • ADR 0052 — the host-side step that implements it for an image artifact
  • ADR 0048, issue 022 — a consumer is a module on a machine, which is what makes the scope derivable
  • ADR 0018 — a digest is the record that something happened
  • ADR 0005 — the control plane decides and never touches a machine
  • ADR 0134 — what makes a failed preparation visible
  • issue 133 — the failure that produced both records