From b69ae663bcc528349c0575adab25733235ea1457 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 21:48:48 +0200 Subject: [PATCH] ADR 0189: the store keeps what the records name, and a maintenance step holds its writers still MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 108: the artifact store has never collected anything. Fifty-three repositories on the machine that serves everything else, and the only outcome of leaving it is a full disk reported as somebody else's failure. The mesh decides what may go — from its own build records, so it never names a digest it did not put there — and the store reclaims the bytes in a nightly window with its server held still. Deletion on the one door takes nothing a push did not already have. Designs 18 and 20 amended; issue 108 resolved. Also issue 202, found running the controller's suite: a module whose required setting nobody set is left out of the machine in silence, and dnsmasq became that module this morning. --- ...9-the-store-keeps-what-the-records-name.md | 131 ++++++++++++++++++ 02-DECISIONS/README.md | 1 + 03-DESIGN/01-to-be/18-building-a-module.md | 41 +++++- 03-DESIGN/01-to-be/20-writing-a-module.md | 27 +++- .../00-report.md | 37 ++++- .../00-report.md | 67 +++++++++ 6 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md create mode 100644 04-ISSUES/202-a-module-whose-required-setting-is-unset-is-silently-left-out/00-report.md diff --git a/02-DECISIONS/0189-the-store-keeps-what-the-records-name.md b/02-DECISIONS/0189-the-store-keeps-what-the-records-name.md new file mode 100644 index 0000000..2d459e2 --- /dev/null +++ b/02-DECISIONS/0189-the-store-keeps-what-the-records-name.md @@ -0,0 +1,131 @@ +--- +topic: the mesh +status: accepted +date: 2026-10-02 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md +--- + +# 189. The store keeps what the records name, and a maintenance step holds its writers still + +## Context + +The mesh's artifact store has never collected anything +([issue 108](../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md)). +Every build pushes another layer set; nothing has ever removed one. The predecessor ran a routine +on a timer — stop the registry, collect, start it — and the conversion carried the settings that +routine depends on without the routine, because the routine was a script beside the module and not +a resource in it. The store now holds fifty-three repositories on the machine that serves +everything else, and the only outcome of leaving it is a full disk reported as somebody else's +failure. + +Three things stood in the way, and the issue names all three. + +**Nothing in the mesh's vocabulary expresses a maintenance window.** The collector requires every +writer stopped while it runs. A `run-once` step runs *beside* containers, not instead of them, and +a scheduled step is the same container on a cadence. There is no way for a module to say *hold this +container of mine still while this runs*. + +**Deletion is not enabled, and the door it would be enabled on has no accounts.** The store is +internal, reached by name over the overlay, trusted because being on that network is the permission +([ADR 0082](0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md)). The predecessor +kept deletion behind an authenticated door, which it could, having one. + +**Nothing says what may be removed.** The registry's own answer — collect everything no tag names — +is wrong here. The mesh pushes each artifact under one moving tag and pins machines by digest, so +every build but the newest is untagged and some machine may still be running it. + +## Decision + +**1. Deletion is enabled on the store's one door, and the overlay stays the permission.** The +objection dissolves on inspection: that door **already accepts a push**, and a writer who can push +can replace any tag in the store with anything it likes. Delete takes nothing a push did not +already have, and the machines that can reach the door are the ones the mesh's own filter admits +([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)). Putting an authenticated +door in front of deletion while leaving push open would be a lock on the window beside an open +door, and it would cost the thing ADR 0082 bought: a store every machine can reach without a +credential to distribute first. + +**2. The mesh deletes what it made and no longer keeps; the store reclaims the bytes.** Two halves, +each doing what only it can. + +The **mesh** decides. It does not need to enumerate the store to do it — it has never put anything +there it did not record, so **every digest it could remove is already in its own build records**. +It deletes those manifests through the store's door, by digest, and remembers that it did. + +The **store** reclaims. A deleted manifest frees no bytes until the registry's own collector walks +the storage with nothing writing to it, so the module declares that collector as a scheduled step +with the server held still for its duration. Plain collection, not `--delete-untagged`: what the +mesh keeps is still a manifest in the store, so it is still referenced, so its blobs stay — the +dangerous flag is not needed at all once the mesh is the one deciding. + +**3. What the mesh keeps, stated as three reasons rather than a number.** A digest is kept because: + +- **a definition names it** — every artifact reference in any module's current recorded manifest, + which is what the mesh would hand a machine now. No age limit: this is the floor; +- **the mesh can still go back to it** — every artifact of the **five most recent successful + builds** of each module, so a release that turns out wrong has somewhere to return to; +- **nothing else.** An artifact older than that, which no definition names, is what the store is + carrying for no stated reason. + +A digest the mesh did not record making is never touched. That is not a safety margin, it is the +whole rule restated: the mesh removes what it put there and can account for, and the images genesis +pushed before any record existed are exactly what this must not reach +([04-ISSUES/102](../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md), F4). + +**4. A scheduled step may hold its module's own containers still while it runs** — +`while-stopped`, naming resource ids in the same module. The host stops each, runs the step, and +starts them again **whatever the step did**, including when it failed or the host was interrupted. +Three boundaries: + +- **Its own module's containers only.** A module that could quiesce a neighbour could stop the + mesh; a maintenance window is a statement about one service's own insides. +- **Scheduled steps only, not `run-once`.** At apply time the host already has a window: the + declaration is applied in order and a step gates what follows, so a one-time offline migration + says *before* rather than *instead of*. A recurring window is the case order cannot express. +- **Restoring is not conditional.** A step that fails must leave the service running; the whole + risk of this field is a window that never closes. + +**5. The sweep runs where the records change — after a build the mesh recorded.** That is the +moment new bytes landed and the moment the keep set moved, and it needs no new timer. The +store's collection runs nightly, because reclaiming is slow and the thing it reclaims is already +unreferenced. + +## Consequences + +- Disk stops growing without bound on the machine that serves the mesh. That is the whole point + and it has no other way to be true. +- A machine behind by more than five builds of a module, which recreates a container, cannot pull + what it was running. It is already a machine the mesh reports as behind, and the answer is the + one the mesh already gives it: the current declaration. Stated here rather than discovered. +- The store is a little less of a museum. A digest in an old build record may no longer be + fetchable, and the record still says what that build made — the record is history, not an + index of what is on disk. The collected mark is kept beside it so the two can be told apart. +- `while-stopped` is a second thing the host does to a container it did not start this pass. It is + deliberately the narrowest form: the module's own, by id, restored unconditionally. +- The store is briefly unavailable each night, for as long as collection takes. Everything that + pulls from it retries; nothing in the mesh treats a momentary store as a failure + ([ADR 0185](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md)). + +## How this is checked + +- The host: a scheduled step with `while-stopped` stops the named containers before the run and + starts them after; it starts them again **when the step fails**; it refuses an id that is not a + container of the same module, its own id, and `while-stopped` on a `run-once` step. Each refusal + is tested for what it says, not only that it says something. +- The controller: given build records and current manifests, the keep set holds every reference a + manifest names and every reference of the five most recent builds per module, and nothing else; + a reference the mesh never recorded is never in the delete set; a delete that answers 404 is + recorded as collected rather than retried forever. +- The sweep is tested against a fake store that records what it was asked to delete, so what is + asserted is the decision and not the registry's behaviour. +- Live: the store's size before and after the first nightly collection, read from the machine. + +## References + +- [issue 108 — the registry has no garbage collection](../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md) +- [ADR 0082 — the registry is reached by name and trusted by the overlay](0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md) +- [ADR 0053 — a step that runs on a schedule](0053-a-step-that-runs-on-a-schedule.md) +- [ADR 0156 — an artifact is what a build produces, and the store is named for its scope](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md) +- [design 32 — what a module declares](../03-DESIGN/01-to-be/32-what-a-module-declares.md) diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index a73aea7..a05f6a0 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -189,6 +189,7 @@ python3 00-META/checks/index.py fail if stale - **0186** — [A ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang](0186-a-ban-list-never-holds-a-neighbour.md) - **0187** — [A dead tracker is not the machine's failure](0187-a-dead-tracker-is-not-the-machines-failure.md) - **0188** — [A provider declares what it derives for each consumer, and the mesh tells both ends](0188-a-provider-declares-what-it-derives-for-each-consumer.md) +- **0189** — [The store keeps what the records name, and a maintenance step holds its writers still](0189-the-store-keeps-what-the-records-name.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/18-building-a-module.md b/03-DESIGN/01-to-be/18-building-a-module.md index 1de14b0..418807c 100644 --- a/03-DESIGN/01-to-be/18-building-a-module.md +++ b/03-DESIGN/01-to-be/18-building-a-module.md @@ -5,9 +5,10 @@ code: - mesh-controller cmd/mesh-builder - mesh-controller internal/builder - mesh-catalog modules/builder -updated: 2026-10-01 +updated: 2026-10-02 decisions: - 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md + - 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md - 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md - 02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md @@ -293,6 +294,44 @@ build's lines reach a reader of its subject in order and the stream holds them a against a real server); the seat verb with an id reads the log (controller test); and, live, a build after the roll-out read line by line through the console. +## The store keeps what the records name + +*2026-10-02 — [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md), +[issue 108](../../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md).* + +Every build pushes another layer set and, until this, nothing ever removed one. The registry's own +answer — collect what no tag names — is wrong for this mesh: each artifact is pushed under one +moving tag and machines are pinned by digest, so every build but the newest is untagged and some +machine may still be running it. + +**The mesh decides and the store reclaims.** Deletion is enabled on the store's one door — that +door already accepts a push, and a writer who can push can replace any tag, so delete takes +nothing a push did not already have, and ADR 0082's bargain (a store every machine reaches with no +credential to distribute first) is kept. The mesh then removes what it put there and no longer +keeps, **naming it from its own build records** rather than enumerating the store: it has never +put anything there it did not record, so a digest it did not record making is never named, which +is what keeps the sweep away from the images genesis pushed before any record existed. + +An artifact stays for one of two reasons and otherwise goes: a definition the mesh holds names it +(no age limit — this is the floor), or it belongs to one of the five most recent successful builds +of its module (somewhere for a wrong release to return to). The sweep runs after a build the mesh +recorded, which is the moment new bytes landed and the moment the keep set moved; it needs no +timer. Deleting a manifest frees no bytes, so the store's own collector runs nightly as a +scheduled step with the server held still — which is what `while-stopped` exists for +([design 20](20-writing-a-module.md)). Plain collection, not `--delete-untagged`: what the mesh +keeps is still a manifest and so still referenced, and the dangerous flag is not needed once the +mesh is the one deciding. + +A machine behind by more than five builds of a module, recreating a container, cannot pull what it +was running. It is already a machine the mesh reports as behind, and the answer is the current +declaration. + +*How it is checked:* the keep set, against records, holds what a manifest names and the five most +recent builds and nothing else; a reference the mesh never recorded is never in the delete set; an +image and an archive are asked for at their own endpoints; a store with deletion off names the +remedy rather than the status code; a store that does not have it is recorded collected rather +than retried for ever. Live: the store's size before and after the first nightly collection. + ## The builder compiles the languages the mesh is written in *2026-09-29 — diff --git a/03-DESIGN/01-to-be/20-writing-a-module.md b/03-DESIGN/01-to-be/20-writing-a-module.md index 6708851..e8e3cd3 100644 --- a/03-DESIGN/01-to-be/20-writing-a-module.md +++ b/03-DESIGN/01-to-be/20-writing-a-module.md @@ -5,11 +5,12 @@ code: - mesh-catalog modules/showcase - mesh-controller internal/builder - mesh-sdk src -updated: 2026-09-30 +updated: 2026-10-02 decisions: - 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md - 02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md - 02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md + - 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md - 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md - 02-DECISIONS/0040-what-a-module-is.md - 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md @@ -208,3 +209,27 @@ is recreated with the new fact ([ADR 0099](../../02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md)). *How it is checked:* the host's unit tests run a step again when its named file changed and not otherwise, and recreate a container naming a step after the step ran. + +## A recurring step may hold its own module's containers still + +*Written 2026-10-02, from [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md) +and [issue 108](../../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md).* + +Some work cannot be done underneath a running service: an artifact store's collector walks the +storage and requires every writer stopped. A `run-once` step runs *beside* containers and a +scheduled one is the same container again, so until this a module had no way to say it — and the +mesh inherited a store that has never collected anything, because the predecessor said it with a +shell script and a script beside a module is not a resource in it. + +A scheduled step may name `while-stopped`: resource ids of **its own module's** containers, which +the host stops before the run and starts again after it, in the reverse order, **whatever the step +did**. Three boundaries, each refused where it can be seen earliest — its own module's containers +only, because a module that could quiesce a neighbour could stop the mesh; scheduled steps only, +because at apply the declaration is applied in order and a step already gates what follows, so a +one-time offline job says *before* rather than *instead of*; and restoring that is not conditional +on anything, because the only real risk of the field is a window that never closes. + +*How it is checked:* the host's unit tests assert stop–run–start in that order, the restart after a +step that **failed**, the reverse order for several containers, and a service left down said +loudly. The controller refuses, from the definition alone, a window with no schedule, one on a +run-once step, one naming a container the module does not declare, and one naming itself. diff --git a/04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md b/04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md index 74a7709..50a03a6 100644 --- a/04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md +++ b/04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md @@ -1,9 +1,9 @@ --- -status: open +status: resolved opened: 2026-09-23 -located-in: [] -fixed-by: -amended-design: +located-in: [mesh-controller internal/inventory, mesh-controller internal/artifacts, mesh-host internal/apply, mesh-catalog modules/distribution] +fixed-by: 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md +amended-design: 03-DESIGN/01-to-be/18-building-a-module.md --- # 108 — The registry has no garbage collection, and two doors make it harder to add @@ -75,3 +75,32 @@ real thing services need, and the mesh cannot express one. images by digest and moves by version — is retention "the digests no recorded build names"? - Who owns the routine when the store and its public door are two modules — the store, since the volume is its? + +## Answered, 2026-10-02 — [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md) + +The three open questions, answered: + +- **A maintenance step, or a backend that does not need its writers stopped?** The step. A + scheduled container may name `while-stopped` — resource ids of **its own module's** containers, + which the host stops before the run and starts again after it whatever the step did. A storage + backend the mesh does not run would be a bigger thing to own than the mechanism it avoids, and + the mechanism is wanted anyway: a service that cannot have work done underneath it is a real + shape and the mesh could not express it at all. +- **Is retention "the digests no recorded build names"?** Nearly. An artifact stays because a + definition the mesh holds names it (no age limit), or because it belongs to one of the five most + recent successful builds of its module. Last-N-tags was the predecessor's rule for a registry + that knew nothing else; this mesh knows what each digest is for. +- **Who owns the routine now the second door is gone?** Both halves, each where it can be. The + **mesh** decides what may go — only it holds the records — and asks the store to drop it. The + **store** reclaims the bytes, because only it can stop its own server. Neither half can be done + by the other. + +And the sharpened point — enabling deletion on a door with no accounts — dissolved on inspection: +**that door already accepts a push**, so a writer who can reach it can already replace any tag. +Delete takes nothing a push did not have. What it does not do is undo ADR 0082's bargain, which +putting an authenticated door in front of deletion would have. + +The second registry process is not built, as the 2026-09-26 note says, so the shared blob cache +and the deletion-cached-by-the-other-door problem never arise. Plain `garbage-collect` is enough: +what the mesh keeps is still a manifest in the store, so `--delete-untagged` — the flag that would +delete images machines are running — is not needed at all. diff --git a/04-ISSUES/202-a-module-whose-required-setting-is-unset-is-silently-left-out/00-report.md b/04-ISSUES/202-a-module-whose-required-setting-is-unset-is-silently-left-out/00-report.md new file mode 100644 index 0000000..36e77bb --- /dev/null +++ b/04-ISSUES/202-a-module-whose-required-setting-is-unset-is-silently-left-out/00-report.md @@ -0,0 +1,67 @@ +--- +status: open +opened: 2026-10-02 +located-in: [mesh-catalog modules/dnsmasq, mesh-controller cmd/mesh-controller] +fixed-by: +amended-design: +--- + +# 202 — A module whose required setting nobody set is left out of the machine, and the resolver is the module it happened to + +## What was observed + +Running the controller's own test suite against the catalogue beside it, 2026-10-02. +`TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves` fails with *"the resolver +was not handed the machines"*. Composing the same machine by hand and listing what it receives +shows why: **dnsmasq contributes nothing at all.** Four resources are composed for that node, all +of them the overlay's. The resolver's package, its configuration, its service and the fact that +carries every machine's name are simply not there. + +The cause is one line added to `dnsmasq`'s configuration earlier the same day: the addresses it +listens on beside the machine's own became an operator setting, +`listen-address=${setting:listen-addresses}`, with no default. A `${setting:…}` nothing sets is +refused, a module that cannot be composed is **left out** rather than failing the whole machine +([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md)), and so a node +assigned the resolver is handed a declaration with no resolver in it. + +The failing test is the symptom that surfaced it. The test is not what is wrong. + +**Proven rather than inferred.** Composing the same machine a second time with +`listen-addresses` set to `127.0.0.1` and nothing else changed, every one of dnsmasq's eight +resources appears — `needs-broker`, `mesh-state`, `package`, `config`, `runtime-dns`, `runtime`, +`service` and `fact-node-zones`. The only difference between a machine with a resolver and a +machine without one is whether somebody set a value that did not exist yesterday. + +## Why it matters beyond this instance + +**Leaving a module out is right, and being quiet about it is not.** The rule exists so one +module's broken setting cannot stop a machine converging — a good rule. But the outcome here is a +machine that applies cleanly, reports current, and is missing its DNS resolver. Every name on that +machine then resolves through whatever was there before, or not at all, and nothing in the mesh +says the resolver was dropped. That is the shape +[issue 152](../152-a-nodes-plan-failure-silently-drops-its-routed-names/00-report.md) records for +routed names, here for a whole module. + +**And a setting with no default is a definition that cannot be assigned.** Every other +`${setting:…}` in the catalogue names something that is genuinely particular to one installation — +a public domain, an issuer. "Which addresses besides my own do I answer on" has an obvious correct +default for every machine that is not a LAN gateway: none beside loopback. A definition that +refuses to compose until somebody sets a value most machines do not need is a definition that +breaks the next node to be assigned it, and genesis with it. + +## What this does not claim + +Whether the live machines are affected was not checked — those four have had the setting set, or +their resolvers would already be gone. The claim is about a machine assigned the resolver *from +now on*, and about the silence. + +## Open questions + +- Should the declaration say which modules it left out, where a person or the console can see it? + `left_out` already travels to the host ([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md)); + what is missing is anything that reads it back and says so. +- Should a `${setting:…}` be allowed a default in the definition — making "unset" mean "the + default" rather than "refuse" — or is a setting with a default no longer the operator's value? +- Is leaving a module out ever right for a module a node is **assigned**, as opposed to one it + merely pulls in? An assignment is somebody saying *this machine runs this*; silently not running + it is the one answer nobody asked for.