Record how the registries keep what is named, so nothing piles up unseen (ADR 0251, to-be 51)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

This commit is contained in:
jochen
2026-10-08 11:51:59 +02:00
parent bd14e1b021
commit f1e2ebce7a
3 changed files with 353 additions and 0 deletions
@@ -0,0 +1,203 @@
---
topic: the mesh
status: accepted
date: 2026-10-08
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
---
# 251. The registries say what they hold and keep what is named, and a machine keeps the image it runs and the one before
## Context
The mesh has three places where built things pile up, and only one of them collects.
**The artifact store collects, and nobody can see it do so.** Since
[ADR 0189](0189-the-store-keeps-what-the-records-name.md) the controller lets go of every artifact it
made and keeps for no reason, after each build, and the store's collector reclaims the bytes each
night. Measured on 2026-10-08 through the controller's `collection` command: 207 archives kept, 207
held by a manifest, 0 eligible to let go. That is the records' half. The store's half has no tool at
all. The module that holds the store carries a TypeScript client and three tools that no build
produces, so nothing on the mesh answers *which repositories are there, how large, and which of what
is there no record names*. The one thing ADR 0189 forbids collection to touch — a digest the mesh did
not record making — is exactly the thing nobody can count.
**The package registry never collects.** Every publish of a package to the forge's npm registry adds a
version, and nothing removes one. Nothing says which versions any build still resolves to.
**A machine collects only what nothing names.** The container runtime's weekly prune takes dangling
images and unused build cache, and nothing else. An image a machine pulled by digest is not dangling,
so every version of every module a machine ever ran stays on it. The build node is the worst case: on
2026-10-08 it held 355 images no container used, about 58 GB summed (images share layers, so the disk
they take together is less).
The operator approved three improvements on 2026-10-08: tools for the store, retention for the package
registry, and pruning of unused images on a machine — each a dry run unless asked otherwise.
## Considered Options
**For the store's tools.**
1. *The store seat's verbs.* The tools a role answers are the seat's, and a second holder of the store
would serve them too. But a verb added to a held seat whose holder lives in another repository lands
in three steps ([ADR 0246](0246-a-seats-new-verb-is-promised-before-it-is-required.md)), and the
store has one holder and no second in view. Not now; the tools can be promoted when a second holder
exists.
2. **The store module's own tools, in Go.** Chosen. They replace the TypeScript client nothing builds.
3. *The controller's verbs.* The controller holds the records but not the store's files; the store may
move to a machine the controller is not on. Rejected for everything except what only the records can
answer.
**For what the store tools read.** The store's door lists repositories and tags, but not a manifest no
tag names — and since the mesh pins by digest, that is almost every manifest. Only the store's files
list them, with each blob's size. So the tools read the store's own files, read-only, through the
store's own container, and read each manifest's content through the door. The registry's own
`garbage-collect --dry-run` was weighed and rejected as the source: it says what is unmarked, but not
how large, and not what letting go of a manifest would free.
**For collecting on demand.** *Letting the store tool delete what the records do not keep* was rejected:
the controller deletes and records that it did, and two deleters means two records. **The same sweep
as after a build, asked on demand,** is chosen.
**For what is unrecorded.** *Removing by age what no record names* was rejected: it is the rule of ADR
0189 §3 broken, and the images genesis pushed are exactly that. The tools count it and size it; a person
decides.
**For the package registry.**
1. *Keep the newest N of each package, and nothing else.* Rejected: a range pinned to an older line
(`^0.1.0` while `0.2.x` is the newest) resolves to a version this drops.
2. *Keep what lockfiles name.* Not enough alone: a module without a lockfile resolves its range at
build time.
3. **Keep what is referenced, and the newest N.** Chosen. Referenced means a version a lockfile on a
repository's default branch names, the highest version satisfying a range a package manifest there
names, and a version a dist-tag names.
**For a machine's images.**
1. *`docker image prune --all`.* Rejected: it removes the previous version, so going back needs a pull,
and it removes the image of a scheduled step between two of its runs.
2. *By age alone.* Rejected: a module not rebuilt for a while would lose its previous version.
3. **Keep what a container uses, what the machine's declaration names, and the newest other image of
each line.** Chosen.
## Decision
**1. The store module answers for the store, with four tools of its own.** The distribution module,
holder of `mesh-artifact-store`, serves a Go bundle in place of its unbuilt TypeScript:
- `store_repositories` — every repository, its tags, how many manifests it holds and its size;
- `store_usage` — the store's size, the largest repositories, what blobs are shared, and what the
store's nightly collector would free now;
- `store_references` — what the records say of each manifest the store holds (below), and what the
records keep that the store does not hold;
- `store_collect` — a dry run unless `dry_run` is false; a real run needs `why`.
The tools read the store's files through the store's own container and only read them. The first three
change nothing; `store_collect` changes nothing either, unless it is a real run, and then only by asking
the controller (point 3).
**2. The records are asked, never copied.** A new controller verb, `artifacts`, answers every
reference the mesh recorded making, with its state: *kept* because a definition names it, *kept*
because it belongs to one of the five most recent builds of a module the mesh holds, *eligible* (made,
not kept, not yet let go), or — when asked — *collected*. `store_references` sets that beside what the
store holds and says, for every manifest, one of:
- **kept**, with the reason;
- **a holder of a kept archive** (the manifest that keeps an archive's blob, [issue 253](../04-ISSUES/253-the-stores-collector-would-delete-every-archive-the-mesh-keeps/00-report.md));
- **eligible**, or a holder of an eligible archive;
- **a named document** — something the controller keeps under a tag (to-be 45 §9);
- **unrecorded** — no record names it.
Unrecorded manifests are counted and sized, by repository. **They are never removed by any tool in this
record.** ADR 0189 §3 stands as written.
**3. Collecting on demand is the same sweep, asked by a person.** A new controller verb, `collect`,
says what the sweep would let go of, and does it only with `confirm` and a `why`. A real run holds every
kept archive before it lets anything go, exactly as the sweep after a build does; it is bounded by a
count and a time, says what it left; and it is recorded as a hand-act with its `why`. `store_collect`'s
dry run adds what only the store knows: how many bytes the nightly collector would free once those
manifests are gone, beside what it would free tonight anyway. Its real run asks `collect`. Bytes are
still reclaimed by the nightly collector, because only it runs with the store held still.
**4. The package registry keeps what is referenced and the newest five of each package.** The forge
module, holder of `npm-package-registry`, gains a Go bundle beside its TypeScript one, with two tools:
- `npm_packages` — every package of the registry's owner, each version with its publish date and size,
and why each is kept;
- `npm_retention` — what retention would delete and how many bytes; a dry run unless `dry_run` is
false, and a real run needs `why`.
A version is kept when:
- a lockfile on the default branch of any repository on the forge names it;
- it is the highest version satisfying a range that a package manifest there names;
- a dist-tag names it; or
- it is among the newest five of its package (`keep` changes the number; it is never below one).
**A repository whose files could not be read stops a real run before anything is deleted.** Retention
that does not know what is referenced does not delete.
**5. A machine removes images no declaration uses, and keeps the one each module runs and the one
before.** The container runtime's module gains `docker_prune_images`. It keeps:
- every image a container on the machine uses, in any state;
- every image the machine's declaration names — both what the mesh would send it now and what it was
last sent — asked of a new controller verb, `images`;
- for each line of images, the newest image besides those. A line is the images sharing a repository
name, joined across names when one image carries several (a build's local tag and the store's name);
- every image younger than a floor, seven days by default — the same week the weekly prune leaves.
It removes the rest by id, without force, so the runtime itself refuses an image a container uses. **It
refuses to remove anything when the controller cannot say what the declaration names.** It is a dry run
unless `dry_run` is false, and a real run needs `why`.
**6. Nothing here runs by itself.** No timer is added: the store's nightly collector and the weekly
dangling prune stay as they are. Each tool is a dry run until a person runs it for real, and whether any
of them becomes scheduled is a later decision, made from what their dry runs said.
## Consequences
- The question "what is in the store that nothing records" has an answer, with a size. Before, it could
only be estimated by reading the store's disk by hand.
- Each controller verb answers in one bus message, and an answer larger than the bus carries is lost
on its way back (issue 314, open while this was written). So `artifacts` leaves out the collected references unless asked,
and `images` answers only images.
- Five builds are kept by two rules now: the store's (ADR 0189 §3) and the package registry's. They
agree on purpose, so "how far back can this go" has one answer.
- On a machine, the previous version of a module is a local image, and the one before that is a pull
from the store. The store keeps five, so a pull succeeds.
- A build node removes base images no declaration names, and pulls them again at its next build. That
is a cost in time, and it is visible in the dry run before anybody accepts it.
- The store module stops carrying code no build produces, and the `image.pushed` event it declared
and never emitted goes with it.
## How it is checked
- **The controller.** `artifacts` names every reference the keep set holds with its reason, and every
eligible reference; a collected reference only when asked. `collect` without `confirm` deletes nothing,
against a fake store that records what it was asked; with `confirm` and no `why`, it is refused; a
real run holds kept archives before letting anything go. `images` names every container image of a
declaration, and of the last one sent.
- **The store's tools.** Against a fake store and a fake listing of its files: classification of each
manifest (kept, holder, eligible, named document, unrecorded); the bytes the collector would free,
counting a blob shared with a kept manifest as kept; a dry run deletes nothing; a real run without
`why` is refused.
- **Package retention.** Range satisfaction (`^`, `~`, exact, `x`, comparison, `||`, hyphen); a version
a lockfile names is kept, whatever its age; the newest five are kept; an unreadable repository stops
a real run; a dry run deletes nothing.
- **Image pruning.** Against a fake runtime: what a container uses, what the declaration names, the
newest other image of each line and anything under the floor are kept; an image with two names is one
line; an unanswered controller refuses; a dry run removes nothing; removal is never forced.
- **Live.** The dry runs against the running store, the package registry and each machine, read before
anything is run for real. Their numbers are recorded in to-be 51.
## References
- [ADR 0189 — the store keeps what the records name](0189-the-store-keeps-what-the-records-name.md)
- [ADR 0246 — a seat's new verb is promised before it is required](0246-a-seats-new-verb-is-promised-before-it-is-required.md)
- [ADR 0109 — a package registry seat is one per ecosystem](0109-a-package-registry-seat-is-one-per-ecosystem.md)
- [ADR 0156 — an artifact is what a build produces](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)
- [issue 253 — the store's collector would delete every archive the mesh keeps](../04-ISSUES/253-the-stores-collector-would-delete-every-archive-the-mesh-keeps/00-report.md)
- [to-be 51 — the registries keep what is named](../03-DESIGN/01-to-be/51-the-registries-keep-what-is-named.md)