From 476cda417d091b856251b6cf3dae61a762a90ea4 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 28 Sep 2026 11:57:16 +0200 Subject: [PATCH] ADR 0135 supersedes 0133: a module version prepares its state before it runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...rations-and-the-mesh-owns-when-they-run.md | 3 +- ...rsion-prepares-its-state-before-it-runs.md | 153 ++++++++++++++++++ 02-DECISIONS/README.md | 3 +- .../01-to-be/32-what-a-module-declares.md | 24 +-- .../00-report.md | 9 +- 5 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md diff --git a/02-DECISIONS/0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md b/02-DECISIONS/0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md index a73092e..ab77e0b 100644 --- a/02-DECISIONS/0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md +++ b/02-DECISIONS/0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md @@ -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 diff --git a/02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md b/02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md new file mode 100644 index 0000000..e63b15e --- /dev/null +++ b/02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md @@ -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 diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 8e06a63..b3e09e5 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -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 diff --git a/03-DESIGN/01-to-be/32-what-a-module-declares.md b/03-DESIGN/01-to-be/32-what-a-module-declares.md index 1987f5e..6e147e6 100644 --- a/03-DESIGN/01-to-be/32-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/32-what-a-module-declares.md @@ -12,7 +12,7 @@ decisions: - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md - 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md - 02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md - - 02-DECISIONS/0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md + - 02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md - 02-DECISIONS/0134-the-mesh-says-what-it-applied.md --- @@ -260,13 +260,15 @@ queue. and publishes it last-per-subject. A node that was away gets exactly the current one, never a queue of superseded ones, and a replayed older one is refused by sequence. -**A version that needs its store prepared prepares it first.** A container may declare steps to run -before it — the same container, to completion, with different arguments — and the mesh derives them as -run-once resources placed ahead of it, so a schema change and the code that needs it arrive together or -not at all. The module owns what the step does; the mesh owns when it runs, and refuses to start the -container if it failed ([ADR 0133](../../02-DECISIONS/0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md)). -Per node, because a node converges without waiting on its neighbours; a step that must happen once -mesh-wide belongs to a module holding a seat, which is what one holder on record already means. +**A version prepares its state before it runs.** A module version may declare an entrypoint that brings +its state to the shape that version needs — the same vocabulary as the entrypoints it declares for its +tools and its provisioner, and nothing about how a machine runs it. The mesh runs that entrypoint as it +runs the module's own code, to completion, in the module's own context, and a version whose preparation +did not succeed does not run: the rollout stops at the first machine that did not take it +([ADR 0135](../../02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md), superseding +[ADR 0133](../../02-DECISIONS/0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md)). +Once per state, and the mesh derives what a state is: a consumer is a module on a machine, so what the +mesh provisions is per consumer and preparation is too. No level to choose, and no race to lock against. **Applying is reported to a role.** The host applies and reports to the `mesh-controller` seat — not to an address it was given at genesis. Held and retried while the store restarts @@ -469,9 +471,9 @@ billing existing under that name. - **A manifest holds no subject.** A catalogue test: no manifest contains a string matching the subject grammar. The rule is worthless if it is followed by convention. -- **A derived step is the container it precedes.** A composition test: the step's image, environment, - volumes and network equal that container's, so the two cannot drift — which is the failure the - hand-written kind has, three times over in the catalogue today. +- **A preparation is given what the module is given.** A composition test: what the preparation + entrypoint receives equals what the module's own code receives, asserted rather than written twice — + which is the drift a hand-written step invites, three times over in the catalogue today. - **A convergence that changed nothing says nothing.** Two identical reports, one emitted fact: what is guarded against is a fact per minute per machine, which is a stream nobody reads. - **Permissions are exactly the three namespaces.** A composition test per module: the derived diff --git a/04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md b/04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md index 98bc035..90833d8 100644 --- a/04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md +++ b/04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md @@ -55,10 +55,11 @@ server starts, which means the old binary briefly runs against the new schema. A removes or renames something would break the running control plane in the window between the two. **A hand-written step is one the next module forgets**, which is why this fix is not where the matter -ends: [ADR 0133](../../02-DECISIONS/0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) -makes the step derived — a container declares what must run before it, and the mesh composes the gated -resource — so the control plane stops being the only module that had to remember. The same record -settles the level question HAL answered with stages, and +ends: [ADR 0135](../../02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md) makes it +derived and puts it where an author works: a module version declares an entrypoint that prepares its +state, and the mesh composes the gated work from it, so the control plane stops being the only module +that had to remember. That record also settles the level question HAL answered with stages — a consumer +is a module on a machine, so the scope of preparation is the scope of the state — and [ADR 0134](../../02-DECISIONS/0134-the-mesh-says-what-it-applied.md) answers the second open question below: what a machine applied, and what it refused, become facts on the bus rather than a line in a log. -- 2.54.0