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)
@@ -0,0 +1,149 @@
---
layer: to-be
status: in-progress
code:
- mesh-controller cmd/mesh-controller (the verbs artifacts, collect and images)
- mesh-catalog modules/distribution (the store's tools)
- mesh-catalog modules/gitea (package retention)
- mesh-catalog modules/docker (image pruning)
updated: 2026-10-08
decisions:
- 02-DECISIONS/0251-the-registries-say-what-they-hold-and-keep-what-is-named-and-a-machine-keeps-the-image-it-runs-and-the-one-before.md
- 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
---
# 51 — The registries keep what is named
**Three places hold built things: the artifact store, the package registry and each machine's
container runtime. Each gets tools that say what it holds, what names each thing, and what could go.
Each removal is a dry run unless a person asks otherwise and says why. What decides is always a
record: the controller's build records for the store, the forge's repositories for the package
registry, and the machine's declaration for its images.**
([ADR 0251](../../02-DECISIONS/0251-the-registries-say-what-they-hold-and-keep-what-is-named-and-a-machine-keeps-the-image-it-runs-and-the-one-before.md),
extending [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md).)
## The parts
```
controller (records)
artifacts ──┐ collect ──┐ images ──┐
│ │ │
store module ─────┴─────────────┘ │
(store_repositories, store_usage, │
store_references, store_collect) │
│ reads the store's files │
│ through its own container │
▼ │
artifact store │
│
forge module (npm_packages, npm_retention) │
│ reads every repository's manifests │
▼ │
package registry │
│
container runtime module ─────────────────────┘
(docker_prune_images), on every machine
```
## The artifact store
### What the store's tools read
The store keeps each blob once, named by its digest, with its size on disk. Each repository lists the
manifests it holds (every one, whether a tag names it or not), its tags, and the layers it may serve.
The tools list those files through the store's own container, read-only, and read each manifest's
content through the store's door. Nothing is written.
From that, a manifest *marks* its own blob, its configuration and each layer, and an index marks the
manifests it lists. The store's nightly collector removes every blob no manifest marks. The tools work
the same mark, so their numbers are the collector's.
### What each tool answers
- **`store_repositories`** — each repository with its tags, its manifest count and its size: the sum of
the blobs its manifests mark. A blob shared by two repositories is counted in each, and the answer
says so.
- **`store_usage`** — the store's total size; the largest repositories; the bytes in blobs that more
than one repository marks; and the bytes in blobs no manifest marks, which is what the nightly
collector frees next.
- **`store_references`** — for every manifest, one state (below), counted and sized by repository, and
listed when one repository is asked about. Then what the records keep that the store does not hold.
- **`store_collect`** — the dry run: what the controller would let go of, and how many bytes the nightly
collector would free once those are gone. A real run with `dry_run` false and a `why` asks the
controller's `collect`, and answers what it did.
### The states of a manifest
| state | what it means | can collection remove it? |
|---|---|---|
| kept — definition | a module's recorded definition names it | no |
| kept — recent build | one of the five most recent builds of a module the mesh holds | no |
| holder of a kept archive | the manifest that keeps a kept archive's blob | no |
| eligible | the mesh made it and keeps it for no reason | yes, through the controller |
| holder of an eligible archive | the manifest that keeps an eligible archive's blob | yes, with its archive |
| named document | a manifest the controller keeps under a tag (to-be 45 §9) | no |
| unrecorded | no record names it | no — counted and sized only |
A holder is recognised by its shape: a manifest with an empty configuration and one layer, which is
the blob of an archive the records name.
### The controller's two verbs for the store
- **`artifacts`** reads only. It answers every reference the mesh recorded making, with its state and,
for kept ones, the reason; collected ones only when `collected` is asked for; narrowed to one
repository when one is named.
- **`collect`** says what the sweep would let go of. With `confirm` and a `why` it runs the sweep that
runs after a build: every kept archive held first, then each eligible reference let go and recorded
as collected, stopping at the first refusal by the store. It is bounded by a count and a time, says
what is left, and is a hand-act recorded with its `why`.
## The package registry
The forge module's Go bundle reads the registry of the owner its seat serves, and the files of every
repository on the forge.
- A **referenced version** is one a lockfile on a repository's default branch names; the highest version
satisfying a dependency range a package manifest there names (dependencies, development, peer and
optional); or one a dist-tag names.
- **Kept** is referenced, or among the newest `keep` versions of its package (five by default).
- **`npm_packages`** answers each package with its versions, each version's publish time and size, and
why it is kept or not.
- **`npm_retention`** answers what it would delete, version by version, and the bytes. With `dry_run`
false and a `why` it deletes those versions through the forge's own interface. A repository whose
files could not be read is named in every answer, and stops a real run before anything is deleted.
Ranges are read as npm reads them: exact, `^`, `~`, `x` and `*`, comparisons, hyphen ranges and `||`.
A range that cannot be read is named, and its package keeps every version until it can.
## A machine's images
The container runtime module's tool `docker_prune_images` works on the machine it runs on.
1. It lists every image, with its names, its digests, its age and its size, and every container, in
any state.
2. It asks the controller's verb **`images`** for this machine: every container image the machine's
declaration names, both the one the mesh would send now and the one it was last sent. No answer
means nothing is removed.
3. An image is **kept** when a container uses it, when either declaration names it, or when it is
younger than the floor (`older_than_days`, seven by default).
4. Images are grouped into **lines**: the images sharing a repository name, joined across names when
one image carries more than one. In each line that holds a kept image, the newest image that is not
already kept is kept too: the previous version.
5. Everything else is removed by id, never forced. The runtime refuses an image a container uses, and a
refusal is reported, not retried.
The answer lists each image with whether it is kept and why, the bytes that would be freed (summed per
image, and stated as an upper bound because images share layers), and, for a real run, what was
removed and what the runtime refused.
## What stays as it is
- The store's nightly collector, held still while it runs (ADR 0189 §4).
- The sweep after each build, bounded (ADR 0189 §5).
- The container runtime's weekly prune of dangling images and build cache.
- No new timer: each tool runs when a person runs it.
## Where it stands
*In progress, 2026-10-08.* The decision is recorded; the code is being built in the controller and in
the store, forge and container runtime modules. The live dry runs are recorded here once the tools run.
+1
View File
@@ -50,6 +50,7 @@ document is written and this one's status becomes `implemented`.
| [`47-delivery-from-commit-to-delivered.md`](47-delivery-from-commit-to-delivered.md) | **Designed.** A delivery is one commit in one repository, from its pull request's head to every machine; a delivery group is deliveries sharing a branch name, ordered and checked as one future state; both owned by the `mesh-delivery` module with one state table, its state on the bus, every transition said, noted on the commit and shown on the pull request; the controller keeps the planner, the gate, sending and the walk, and the core's own updates never wait for the module | [ADR 0239](../../02-DECISIONS/0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md), [ADR 0238](../../02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md), [ADR 0236](../../02-DECISIONS/0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md) |
| [`49-the-mesh-in-domains.md`](49-the-mesh-in-domains.md) | **In progress.** Every concept belongs to one of ten domains, which owns its one word; the glossary is organised by them and is the authority, a retired word is named on its replacement's line with its scope, and two checks hold this repository's documents and the catalogue's tool descriptions to it | [ADR 0244](../../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md), [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md), [ADR 0008](../../02-DECISIONS/0008-a-context-owns-its-store.md) |
| [`50-split-dns-on-a-machine-with-a-vpn-client.md`](50-split-dns-on-a-machine-with-a-vpn-client.md) | **In progress.** A machine whose VPN client pushes servers of its own runs a resolver of its own, on a node seat, that routes the VPN's domains over its link and every other name to the mesh's resolvers; the VPN client's module carries the adapter | [ADR 0247](../../02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md), [ADR 0223](../../02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md) |
| [`51-the-registries-keep-what-is-named.md`](51-the-registries-keep-what-is-named.md) | **In progress.** The artifact store, the package registry and each machine's container runtime get tools that say what they hold, what names each thing and what could go; every removal is a dry run unless a person asks and says why, and what decides is always a record | [ADR 0251](../../02-DECISIONS/0251-the-registries-say-what-they-hold-and-keep-what-is-named-and-a-machine-keeps-the-image-it-runs-and-the-one-before.md), [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md) |
## Not yet written