Compare commits

..
Author SHA1 Message Date
mesh-admin 5938d40dee Merge pull request 'ADRs 0164–0166 and issue 190: the container runtime gets a module, a seat and declared settings' (#271) from decision/docker-module into main 2026-10-02 20:14:58 +00:00
mesh-admin f062672f83 Merge pull request 'Issues 192 and 193: the console reaches a person only by hand; the store's read-only query is not' (#272) from issues/192-193-console-registration-and-store-query into main 2026-10-02 20:14:46 +00:00
jschoubben e4a0c73e2b Merge remote-tracking branch 'origin/main' into issues/192-193-console-registration-and-store-query 2026-10-02 22:14:24 +02:00
jschoubben d1aeee42a4 Regenerate the decision index after merging main 2026-10-02 22:14:21 +02:00
mesh-admin 81d780f973 Merge pull request 'Design 38: WP3 built — node-tools beside mesh-tools, the three things the plan did not say, and the gate refuses spreading not standing' (#304) from design/38-wp3-built-and-the-gate-softened into main 2026-10-02 19:57:16 +00:00
jochen 3e30846e0f Design 38: point at ADR 0069's real file name 2026-10-02 21:46:34 +02:00
jochen b3f18c54c6 Design 38: WP3 built — node-tools beside mesh-tools, the three things the plan did not say, and the gate refuses spreading not standing 2026-10-02 21:46:01 +02:00
mesh-admin e11bf320c9 Merge pull request 'ADR 0188: a module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime; design 38 gains WP1b' (#300) from decision/0187-a-modules-own-code-is-bundles-in-any-language into main 2026-10-02 19:27:39 +00:00
jschoubben da8b4b4ee4 Renumber to ADR 0188: 0187 landed on main first, as the dead-tracker record
Two records shared 0187 (issue 155's collision); the branch landing last
renumbers, and this is it. Only the number changes.
2026-10-02 21:01:02 +02:00
jschoubben c026d5221e Merge remote-tracking branch 'origin/main' into renumber-0187 2026-10-02 21:00:45 +02:00
jochen 709240ec1f ADR 0187: a module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime; design 38 gains WP1b
The operator's direction, absent from every record until now: the SDK must not limit who writes a
module; tools and services may be written in any language; one module may ship several bundles
(tools, a seat's implementation, a daemon); skeleton first, a full implementation when the work
requires it. ADR 0175 had the runtime import a bundle, which only JavaScript can be.

0187 makes a tools bundle a process the node's runtime launches and speaks MCP over stdio to —
the vocabulary the runtime already speaks outward — so any language with an MCP library can write
one today and the mesh's SDK per language is thin; the transport stays in the runtime (0039's
refusal, kept). Importing a TypeScript bundle is the shortcut, not the contract. Notes in 0175,
0039 and 0150 say where their mechanism moved; design 38 records WP1 as built and adds WP1b (the
launcher and the skeleton SDKs); the glossary's bundle widens.
2026-10-02 18:56:57 +02:00
jschoubben 17ca9a262b 0164: a setting names the file it lands in (issue 198's leak between one module's files); 190 notes the fourth machine now has the resolver 2026-10-02 12:23:28 +02:00
jschoubben 967c793eaa Merge remote-tracking branch 'origin/main' into decision/docker-module 2026-10-02 12:23:27 +02:00
jschoubben 27c1db8a86 Review of 0164-0166 and 190: the mesh's own setting words stay settable; changing runtime verbs are not the console's wildcard; migration steps 1-2 are one push; dnsmasq's dns key dates from 09-23 2026-10-02 00:48:06 +02:00
jschoubben 24aeb203f7 Issue 193 resolved: both readers live on every machine, checked by asking each copy who it is 2026-10-02 00:35:03 +02:00
jschoubben afbfd5f29d Issue 193: mssql's variable substitution and shell commands, proven and fixed by mesh-catalog PR 210 2026-10-02 00:25:48 +02:00
jschoubben 696957aa5e Issue 193: proven on a throwaway server, fixed for postgres by mesh-catalog PR 209; mssql has the same hole 2026-10-02 00:09:38 +02:00
jschoubben 3d54fcbb86 Merge remote-tracking branch 'origin/main' into decision/docker-module 2026-10-02 00:02:57 +02:00
jschoubben b13ef1be81 Issues 192 and 193: the console reaches a person only by hand; the store's read-only query is not
192: no provision says where the console is, its port was never assigned, and nothing
owns a person's agent configuration since the predecessor left. 193: the query verb wraps
the caller's text in a read-only transaction the text can end, and its rows come back
keyed by BEGIN.
2026-10-02 00:02:51 +02:00
jschoubben f1941304cc ADRs 0164-0166 and issue 190: the container runtime gets a module, a seat and declared settings
Proposed for the operator's review: settings declared with defaults and cost (0164),
container-runtime as a kernel capability (0165), node-container-runtime seat with the
host creating containers through its holder (0166), and the runtime's file written by
modules that are not its own (190).
2026-10-01 23:13:18 +02:00
21 changed files with 874 additions and 481 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ term retired here may still appear there, and the mapping above is how to read i
memberships issue; its serving mode on loopback is what was called **the console**
([ADR 0175](../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
Replaces **"console"** as the module's name; *console* remains the word for the person's end of it.
- **bundle** — the artifact a module's tools are built into, interpreted or compiled; never an image.
- **bundle** — the artifact a module's own code is built into — its tools, a seat's implementation, a daemon — in any language the mesh has a toolchain for, interpreted or compiled; never an image. One module may declare several ([ADR 0188](../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)).
- **kept region** — a marked block in a managed file the mesh writes *into*, where the operator's own
lines survive every push and are given back when the module goes
([ADR 0174](../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
@@ -8,6 +8,8 @@ reconstructed: false
# 39. What the SDK holds, and what it refuses
> **The mechanism changed — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** The test of this record — frequent *and* cascading does not belong — stands and now applies to one SDK per language. Where *what it holds* names the broker client and the event consumer, read: the local protocol a tools bundle speaks to the node's runtime; the transport lives in the runtime and in no SDK, which is what keeps a bus change from rebuilding any module in any language.
_Reconciliation note (2026-09-05): supersedes the earlier "repository structure" decision, which the consolidation folded; no standalone record remains to point at, so body references to it now point at the nearest surviving record, [ADR 0015](0015-applications-live-in-their-own-repository.md)._
## Context
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-ow
# 150. A module's own code runs as supervised processes under the module's one account
> **Widened — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** A module's long-lived process is a bundle in any language the mesh has a toolchain for, run as a unit the host writes; this record never said one language and never meant one, and 0188 says so as the rule.
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** For a module's *tools*, read that record: one runtime per node, the node's one account, bundles loaded from the memberships. This record still governs a module's long-lived processes — a daemon, a provisioner, a scheduled ingest — and the account invariant for them.
## Context
@@ -0,0 +1,146 @@
---
topic: what runs on it
status: proposed
date: 2026-10-01
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md
---
# 164. A setting is declared with its default, its meaning and what changing it costs
## Context
The operator asked for one thing for every module, with the container runtime as the first case: **one
consistent default configuration for every machine, overridable per assignment, and easy to change
later.** The four machines' runtime configurations were each written by hand and differ — one keeps
running containers through a daemon restart and one does not, their log rotation differs, and each
names its resolver and its trusted registries in its own words.
Most of this was already decided.
[ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) said the definition is
identity and **defaults**, the assignment's settings are the configuration, *unset is the default*, and
*an unknown setting is refused*. [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) made
a setting an operator requirement whose contract is "a type and, optionally, a default", answered by
"the assignment's, or the requirement's default, or unresolved". An assignment is a module on a node,
so the node layer of a module's settings already *is* the per-assignment override, and the mesh-wide
layer is the one consistent default a person changes once.
What was built is narrower than what was decided, measured in the controller on the day of deciding:
- **Nothing declares which keys are settable.** A mergeable file's content is its defaults, and every
key of it — and every key not in it — is accepted. Nothing tells a person, or the console, what can be
set, of what type, or what it means.
- **The refusal of an unknown setting is not there for most modules.** The stray-setting report returns
nothing at all for a module with any mergeable file, because such a file "takes any key"
([issue 173](../04-ISSUES/173-a-modules-settings-reach-every-fact-it-contributes/00-report.md) left
files that way on purpose). It reports rather than refuses where it does run.
- **A value in a file that is not JSON can have no default.** `${setting:<key>}`
([ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md)) is refused when no
layer sets it — right for a mail domain, where a default is the very literal 0155 removes, and wrong
for a tunable like the resolver's upstreams, which the resolver module therefore carries as literals
in its file.
- **A setting reaches every mergeable file its module owns.** The layers are one flat map per module,
laid over each such file. Adding a setting to the resolver module for its own configuration put the
key into the container runtime's file as well — the resolver writes into that file too — and the
runtime refuses keys it does not know. The plan showed it before any push; the runtime's file was
then made to take no settings at all ([issue 198](../04-ISSUES/198-the-lans-dns-server-ran-outside-the-mesh-and-its-filter-closed-it/00-report.md)). Issue 173 stopped settings leaking into
contributions and served facts; between one module's own files the leak remains.
- **What a change costs is said per file, not per key.** A service names the files it is reloaded or
restarted on. The runtime re-reads its trusted registries on a reload and its `dns` key only when it
starts; the resolver module declared a reload, so on two machines the key was written, reloaded,
and never read, and every container got a public resolver for weeks while everything read as
current ([issue 110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/01-resolution.md)).
## Considered Options
1. **Leave settings implicit; document each module's keys in its README.** Rejected: a key the mesh
does not know cannot be refused, typed, listed by the console or costed, and a README is a rule
enforced by nothing.
2. **A second mechanism for tunables beside settings** — defaults in a new block, settings untouched.
Rejected: two ways to state one person's value, and design 27 already retires six mechanisms
that grew that way.
3. **Settings declared in the definition, as 0112's operator requirement: a key, a type, a meaning,
optionally a default, and what a change costs.** Adopted.
## Decision
**A module declares every setting it takes.** Each declared setting has a name, a type, one sentence
of meaning, optionally a default, and what a change to it costs. The spelling is design 27's to settle
with the rest of the requirement form; this record decides the content.
**A setting with a default is a tunable; a setting without one is the operator's.** A tunable resolves
to its default when no layer sets it — wherever it is read, a mergeable file or `${setting:<key>}` in a
file of any format. A setting with no default is refused by name when nothing sets it, as 0155 decided;
0155's refusal is narrowed to exactly that case, not changed for it. Whether a value has a default is a
fact about the software (a log size does, a mail domain does not), and the definition states it once.
**The layers stay as they are, and every value says where it came from.** The definition's default,
then the mesh-wide layer, then the node's — later wins, objects merge, lists replace. One consistent
configuration for every machine is the default plus the mesh-wide layer; one machine that differs says
so in its own layer and nothing else. Asked for a module's configuration on a machine, the mesh lists
every declared setting with its effective value and its source: *default*, *mesh*, or *node*.
**Changing later is changing one of three places, and the plan shows its reach before anything moves.**
A new default ships with the module's next version and reaches every assignment that does not override
it; a mesh-wide setting reaches every assignment of the module; a node's reaches one. The plan of a
change names each assignment whose effective value moves.
**A declared setting says where it lands.** Each names the file or files of its module that read it,
and reaches no other: a module that owns two mergeable files no longer has one flat map laid over both.
A file that names no setting takes none.
**A declared setting is the only kind accepted.** Setting a key the module does not declare is refused
when it is set, naming the declared keys, rather than reported when the machine is planned. The mesh's
own words — where a port, a directory or an operator's data is placed, how far an endpoint reaches —
are the mesh's to validate as they are today, and no module declares them. A module
that declares no settings keeps today's behaviour until it does; a catalogue test lists those modules,
and the list shrinks to empty before the implicit form is removed — design 27's rule for every retired
mechanism.
**A setting says what it costs: nothing, a reload, or a restart.** When a file changes, the host
applies the strongest cost among the settings whose values moved in it, so a key the software reads
only at start can no longer be written and never read. A setting that reaches a container's environment
costs that container being recreated, which the host already does when a container's specification
changes; it needs no declaration. A service's `reload-on` and `restart-on` keep
naming the files that are not settings — a generated roster, a credential.
**The container runtime is the first module to declare its settings** and the model for the rest:
its log rotation, keeping containers through a daemon restart, and its resolver are tunables, and
its trusted registries are what the mesh tells it.
## Consequences
- The console can show a module's settings as a form: what can be set, of what type, its default,
and where the current value came from. That is the surface the operator wants for changing a
default later.
- `settings set` can refuse an unknown key, so ADR 0046's rule is enforced where it was only stated.
- The resolver's upstreams, the runtime's log rotation, and other literals a definition carries
because it could not give them a default become declared tunables.
- **What got harder:** every module that takes settings must list them, and a mergeable file no
longer silently accepts a key its author did not foresee. A person who needs one adds it to the
definition, which is a new module version, not a setting.
- Issue 173's open question — a consumer checks nothing against a contract — is unchanged; this record
is the operator half of design 27's contract, not the provider half.
- Not decided here: the spelling (design 27); whether a node's layer may be narrowed to a single key
rather than replaced whole, as `settings set` does today.
## How this is checked
| Rule | Checked by |
|---|---|
| Every setting a module takes is declared | A parser test refusing a setting declaration without a type or meaning; a catalogue test listing modules with mergeable files or `${setting:}` and no declarations, which must be empty before the implicit form is removed |
| A tunable resolves to its default; an operator value without one is refused | Resolution tests: an unset tunable in a JSON file and in a text file both take the default; an unset setting with no default is refused naming it (0155's existing test) |
| A setting reaches only the files it names | A resolution test: a module with two mergeable files and a setting declared for one; the other file's content is unchanged by it (the case of issue 198) |
| An undeclared key is refused when set | A controller test: `settings set` with an undeclared key fails naming the declared keys, and nothing is stored |
| Every effective value names its source | A test listing a module's configuration on a node with one key from each of default, mesh and node |
| A change's reach is shown before it moves | A plan test: changing a mesh-wide setting names every assignment whose effective value moves and no other |
| The strongest cost applies | A host test: a file where a reload-cost key and a restart-cost key both moved restarts; a file where only reload-cost keys moved reloads |
| Live | The container runtime's module lists its settings with their sources on every machine, and a mesh-wide change to its log rotation reaches all four at the next push |
## References
- [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)
- [Design 27 — a module requires, the mesh resolves](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md)
- Issues [110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md), [173](../04-ISSUES/173-a-modules-settings-reach-every-fact-it-contributes/00-report.md)
- mesh-controller `internal/catalogue/settings.go` (`settle`, `UnusedSettings`), `internal/catalogue/setting_into.go`
@@ -0,0 +1,105 @@
---
topic: what runs on it
status: proposed
date: 2026-10-01
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0161-what-deserves-a-seat.md
---
# 165. `container-runtime` is what a machine can run; that a runtime is running is its holder's health
## Context
A capability is a requirement a module places on a machine, detected by the host and renewed with
every report ([ADR 0161](0161-what-deserves-a-seat.md)). The host's `container-runtime` asks the
daemon for its version: *a running daemon, not an installed client*. It was made that way by
[issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md), where an
installed package was believed to be a working service, and
[design 05](../03-DESIGN/01-to-be/05-the-node-host.md)'s table says the same: *a runtime is
running*. The installer's preflight borrows the same detector to wait for the runtime the
foundation bundle installs, so there is one answer to "is there a runtime here".
The mesh is now to have a module for the runtime itself — its packages, its configuration, its
service — on every machine ([ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)).
That module cannot declare `container-runtime` as defined: it would require the very thing it
installs, the cycle [research 011](../01-RESEARCH/011-the-module-graph/cases.md)'s case 12 names
("something the mesh installs that then becomes a node capability"). The operator defined the word
for it: **`container-runtime` means the machine is able, at the kernel level, to install a runtime and
execute containers** — not that one is installed, and not that one is running.
The host already draws this line once. `seat` is hardware, a display server *could* run here;
`graphical-session` is state, one *is* running; the detector's own comment says "assignment needs the
first". A machine without a display has no seat however much software is installed, and a machine
with one has a seat before anything is.
Fifty-four catalogue modules declare `container-runtime` today, counted on the catalogue's main
branch on the day of deciding: every module that delivers a container. Each relies on the current
meaning to keep it off a machine with no running runtime.
## Considered Options
1. **Keep the meaning; let the runtime's module declare nothing.** Rejected: a module that installs
the runtime has requirements on the machine — the kernel features without which installing it is
pointless — and would state none of them. The cycle stays, only hidden.
2. **Two capabilities, "can run" and "is running".** Rejected: the second is made true by assigning a
module, so it is the module's state, not a fact of the machine; a capability the mesh itself
flips by its own assignment is case 12's cycle with an extra name.
3. **The capability is the kernel's; whether a runtime runs is the runtime module's health, and a
module that delivers a container needs the runtime's seat held.** Adopted.
## Decision
**`container-runtime` is detected from what the kernel offers**, as `seat` is: the namespaces a
container needs, a control-group hierarchy the runtime can manage, and an overlay filesystem the
running kernel has or can load. Present when all three are; absent naming the missing one. Nothing is
run and no runtime is asked. The verdict's detail names what was found, not a runtime's version.
**"A runtime is running and answers" is one probe, owned by the host and used twice:** by the
installer's preflight, which waits for the runtime the foundation installs, and as the runtime
module's health. It asks the daemon, as issue 007 requires. The preflight stops borrowing the
capability's detector, and there is still one answer to "is a runtime running here".
**The runtime's module declares `container-runtime`**, with `package-manager`, `service-manager` and
`privileged`, like any module that manages machine software.
**A module that delivers a container needs the runtime seat held on its machine**, and is refused
otherwise, naming the seat and the modules that could hold it — the refusal design 27 already lists
for an unheld seat. That requirement is derived from the container resource and needs no manifest
field ([ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)).
The fifty-four existing declarations of the capability stay valid and become redundant; a catalogue
test lists them, and they retire when the list is empty.
**The order is fixed, not preferred.** The detector changes only once the seat requirement is
enforced. In between, a machine with the kernel and no running runtime would read as able to run
every containerised module, which is issue 007 again.
## Consequences
- Design 05's capability table changes its `container-runtime` row from *a runtime is running* to
*the kernel can run containers*, and names the runtime module's health as where "running" is now
asked.
- The node listing stops showing the runtime's version beside the capability. The version moves to
the runtime module's health and its seat's verbs.
- A fresh machine with no runtime reads as able to run one, so it can be assigned the runtime's
module, which is what makes the mesh able to install the runtime instead of the bootstrap alone.
- **What got harder:** "is this machine running containers" is no longer one glance at the profile;
it is the runtime seat's holder and its health. The node's listing should show both side by side.
## How this is checked
| Rule | Checked by |
|---|---|
| The capability is the kernel's | Host detector tests over a fixture `/proc` and `/sys`: all three present → present; each one missing → absent naming it; no runtime binary on the fixture machine changes nothing |
| One probe asks whether a runtime runs | A host test that the preflight and the runtime module's health call the same probe, and that the probe fails against a stopped daemon with an installed client (issue 007's shape) |
| A containerised module needs the runtime seat held | A resolution test: a module with a container resource on a machine whose runtime seat is unheld is refused, naming the seat and its candidate holders |
| The order holds | The host release that changes the detector is gated on the controller release that enforces the seat requirement — stated in both changes' descriptions and checked at review |
| Live | Every machine's profile shows `container-runtime` present with the kernel's features as its detail; a machine with no runtime installed reads present too |
## References
- [ADR 0161](0161-what-deserves-a-seat.md) — the profile renewed by every report; a capability that names a dialect
- [ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md) — the seat and its holder
- [Issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md), [research 011](../01-RESEARCH/011-the-module-graph/cases.md) cases 12–13
- [Design 05 — the node host](../03-DESIGN/01-to-be/05-the-node-host.md)
- mesh-host `internal/profile/detectors.go` (the runtime detector), `internal/profile/seat.go` (the hardware/state split), `internal/bootstrap/preflight.go` (the preflight that borrows it)
@@ -0,0 +1,161 @@
---
topic: what runs on it
status: proposed
date: 2026-10-01
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0161-what-deserves-a-seat.md
---
# 166. The container runtime is a node seat, and the host creates containers through its holder
## Context
Every container the mesh runs on a machine is created by the host, which looks for a runtime
(`docker info`, then `podman info`) and drives that runtime's command line itself: run, inspect,
remove, exec. Research 012 called this "detected rather than declared": the host takes over whatever
runtime it finds. Nothing in the mesh owns the runtime. Its package came from the foundation bundle
or was already on the machine. Its configuration file was written by hand, differs on each of the
four machines, and is also written into by two modules that are not the runtime's
([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)).
Its service is declared by those same two.
The operator set the direction:
- a module for the runtime, on every machine, owning "what is needed to run containers here": its
packages, its configuration and its service;
- that module holds a node seat for the runtime, so a second runtime (podman) can later compete
for the seat;
- the host stops speaking to the runtime directly and uses the seat's holder. The host stays the one
that decides, and the holder becomes the one that executes;
- every container on the machine is in scope, not only the mesh's. A development environment started
by hand, or a test database a tool runs, is legitimate. The host already calls these *strays*: 3,
8 and 25 on three of the machines on the day of deciding;
- the runtime's events and verbs are subjects on the bus, and the mesh's own interface is built on
them. The third-party interface run until now was removed by hand.
[ADR 0161](0161-what-deserves-a-seat.md)'s test for a seat is whether the mesh's own code finds it by
name. Here it does: the host would look up the holder on its own machine. A singular role of a module
held once per machine is a `node-*` seat ([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)),
in the controller's seed.
The constraint that decides most of this record is a cycle. The bus runs in containers. On the
broker's machine, the broker's own container is created by the host. A holder's code served from a
container cannot create the container that runs it. On a first machine, before the controller exists,
nothing holds anything.
## Considered Options
1. **The host calls the holder's verbs over the bus.** Rejected: with the broker down, no machine can
create any container, including the broker's. The mesh would be unable to restart its own
transport.
2. **The holder picks a dialect that the host speaks itself, as with the uplink.** Rejected: the
host would still drive the runtime, and the module would drive it too for every other caller.
That is two programs speaking to one daemon, and they come to disagree about the same machine
(the installer's preflight already exists to avoid this).
3. **The holder's code runs as a supervised process on the machine and serves the seat's verbs
twice: locally to the host, on the bus to everyone else.** Adopted.
## Decision
**`node-container-runtime` is a seat of the mesh's own, node-scoped,** in the controller's seed under
this record. It delivers no provision; what it carries is its role's protocol: verbs its holder must
serve ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)) and events its holder emits
([ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md)). The runtime's module, `docker`, claims
it and is assigned to every machine. A podman module may claim it later; one machine runs one.
**The seat's verbs cover every container on the machine:** list, inspect, logs, stats, start, stop,
restart, create and remove. A mesh-held container is marked by the host's label and says which
assignment holds it. **A container the runtime runs can be root on the machine** — privileged, a host
path mounted, the host's network or process namespace, the runtime's own socket — so a caller other
than the host may not create one that is any of these; only a declaration the mesh composed may ask
for them. And the verbs that change anything are granted by name, never by a wildcard: a grant of
every tool (the console's today) reaches the reading verbs only. Issue 193 is what a verb that trusts
its caller costs. **Creating or removing a mesh-held container is the host's alone.** Any other
caller is refused naming the assignment, because the host would undo it at its next apply. Starting,
stopping or restarting one is allowed, and the answer says the host will restore what its
declaration says. A container the mesh does not hold is the caller's to do anything with.
**The seat's events are the runtime's own** — a container created, started, stopped, died, removed,
its health changed. They are emitted on the seat's subjects, so every holder emits the same events and
no reader depends on which runtime holds the seat. As
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
decides, the subjects are issued by the controller, not composed by the module.
**The holder's code is a supervised process, not a container** ([ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)).
A runtime cannot be run by the thing it runs. The process serves the seat's verbs on the bus to the
console, to tools and to the mesh's interface. The same verbs are served on a local socket on the
machine, which only the host may use. **The host creates, inspects and removes its containers
through that socket and nothing else.** If the holder does not answer, the host creates nothing. It
says so in its report, naming the seat. It never falls back to the command line.
**A container needs the seat held on its machine.** An assignment that delivers a container on a
machine whose runtime seat is unheld is refused, naming the seat and its candidates
([ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md)).
Mounting the runtime's socket into a container is granted by the seat, not by the capability. The
socket's path is the holder's to state, because podman's is not docker's.
**The runtime module owns the runtime's configuration.** Its settings are declared with defaults
([ADR 0164](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md)):
the resolver containers use, the registries it trusts, log rotation, and keeping containers through a
daemon restart. The module is given the resolver's address and the mesh's registry as values; no other
module writes the runtime's file or declares its service.
**The first machine is bootstrapped with the holder, and adopted afterwards.** The foundation bundle
already installs the runtime's package and service. It also carries the holder's process, delivered as
a binary the way the host is ([ADR 0142](0142-the-mesh-delivers-its-own-components-as-binaries.md)).
When the runtime module is assigned, it adopts what the bundle made, as the store and broker modules
adopt theirs ([ADR 0078](0078-the-store-and-broker-are-modules.md)).
## Consequences
- **The migration on the running mesh has a fixed order:**
1. Each machine's hand-written configuration is read, because the module's defaults replace what
differs.
2. In one push per machine: the resolver module and the private network stop writing the
runtime's file ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)),
and the runtime module is assigned and adopts the runtime, its file and its service. Split in
two, either the controller refuses two modules declaring one path, or a machine is left with
nothing setting `dns` and `live-restore`.
3. The controller seeds the seat and enforces the container requirement.
4. The host releases the version that uses the holder.
5. The host's command-line path is removed in the release after every machine's holder answers.
Until then, the host reports per machine which path it used.
- **Every container the host makes depends on the holder's process.** A crash-looping holder stops
new containers on its machine. Running containers are unaffected. The host's report names the cause.
- The process form of a module's own code must serve tools on the live mesh before this ships. Only the
showcase declares it, and [issue 117](../04-ISSUES/117-a-modules-own-code-is-a-container-and-a-process/01-diagnosis.md)
found the showcase's tools declared in a form nothing runs. The runtime module is the first whose
tools cannot fall back to a container.
- A user interface subscribing to events directly does not exist. Today a reader of events is a module
that consumes them. The mesh's container view is a module, or waits for that path.
- [ADR 0005](0005-the-node-host.md) ("a container runtime is detected, not chosen") and
[ADR 0006](0006-the-substrate-and-the-control-plane.md)'s matching line describe the mechanism this replaces: on
acceptance, each gets a dated note saying the runtime is now a seat's holder, as the decision
records' rule for a moved mechanism requires. Design 05 and design 26 are amended after acceptance.
- The operator's decision to remove the third-party interface by hand needs no mechanism. No
module-retires-module rule is introduced.
- **What got harder:** the host gains a dependency it did not have, and a first machine's bundle gains
a component. The direct path was simpler and is what makes a runtime a black box to the rest of the
mesh.
## How this is checked
| Rule | Checked by |
|---|---|
| The seat is the mesh's own, node-scoped, with its verbs and events | A catalogue test on the default seats; registration refuses a claimant that does not serve every verb (design 33's existing check) |
| Creating or removing a mesh-held container is the host's alone | A test of the runtime module's verbs: create or remove of a container carrying the host's label, from any caller but the host's socket, is refused naming the assignment; the same verbs on an unlabelled container succeed |
| No caller but the host creates a container that is root on the machine | A test of `create` from the bus: privileged, a host path, the host's namespaces and the runtime's socket are each refused; the same request on the host's socket is accepted. A broker test: a grant of every tool does not reach a changing verb |
| The host uses the holder and never the command line | A host test with a fake holder on the local socket: every container operation goes to it, and with the holder absent the apply creates nothing and reports the seat; after step 5, the host carries no command-line runtime code (checked by build: the package is gone) |
| A container needs the seat held | A resolution test refusing a containerised assignment on a machine with the seat unheld, naming the seat |
| Socket mounts are granted by the seat | A catalogue test: a module mounting the runtime's socket on a machine whose holder states a different path is refused |
| No other module writes the runtime's file | The existing collision check, once the private network's computed resources are inside it ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)) |
| Live | `seats` lists `node-container-runtime` held on every machine; the node listing shows each machine's containers, strays included, from the seat's `list` verb; a container started by hand appears as an event on the bus |
## References
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [ADR 0161](0161-what-deserves-a-seat.md)
- [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md), [ADR 0142](0142-the-mesh-delivers-its-own-components-as-binaries.md), [ADR 0078](0078-the-store-and-broker-are-modules.md), [ADR 0005](0005-the-node-host.md)
- [ADR 0164](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md), [ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md), [issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)
- [Design 26 — the seats](../03-DESIGN/01-to-be/26-the-seats.md), [design 33 — the tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)
- mesh-host `internal/apply/apply.go` (the runtime lookup and the command line it drives)
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under
# 175. One tool runtime per node serves every module's tools, on the host side
> **The mechanism changed — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** Everything decided here stands: one runtime per node, host-side, every module's tools and every held seat's verbs on the memberships' subjects, root the module's concern, any node calls any tool, the console its serving mode. What moved is how the runtime brings a bundle to life. Decision 3 and the consequence *the node tools runtime needs an interpreter on the machine* read as though a bundle were always interpreted code the runtime imports; a tools bundle is now a process in any language that speaks MCP over stdio to the runtime, and importing a TypeScript bundle is the shortcut, not the contract.
## Context
A module's tools are code the module wrote, one function behind each verb, served on the subjects
@@ -0,0 +1,127 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
---
# 188. A module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime
## Context
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) put one
tool runtime on every node and said a module brings its tools as a bundle. The runtime that exists
is written in TypeScript and brings a bundle to life by **importing it into its own process**, which
only JavaScript can be. The SDK ([ADR 0039](0039-what-the-sdk-holds-and-refuses.md)) is one
TypeScript package. The builder knows three toolchains — TypeScript, Go, Python — and every one of
the 35 catalogue modules with tools wraps them in a container on the runtime's TypeScript image.
Nothing in the records says a module's code may be written in anything else, and nothing refuses a
module that wraps its own code in an image to get around that.
The operator's direction, stated on 2026-10-02 and repeated: *the SDK is the most important part;
we must not limit developers; tools can be written in any possible language — Rust, C, Go,
JavaScript. A service in Go or Rust as a systemd unit must be possible too. One module can deliver
all kinds of bundles: one for its tools, one for a seat's implementation, one for a daemon. Support
the bare minimum first, as a skeleton; a full implementation comes when the work requires it.*
Measured against that: the `bundle` artifact kind already names a language and the `process`
resource already runs a command from an unpacked bundle as a unit the host writes
([ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)), so a Go
daemon as a native service is possible today and one module in the catalogue does it. What is not
possible is a tool in any language but one, and what is not written is that any of this is the
rule.
## Considered Options
1. **One SDK, one language, as now.** Rejected: it limits who can write a module to one
ecosystem, which the operator declines, and it is what made every module's tools a container
on one image.
2. **A full bus client per language.** Each SDK speaks the bus itself; the runtime only
supervises. Rejected: a transport in every SDK is what ADR 0039 refuses, and a bus change
would then rebuild every module in every language — the cascade, multiplied.
3. **A tools bundle is a process the runtime launches and speaks a small local protocol to,
and that protocol is MCP over stdio.** Chosen. The runtime already speaks MCP outward (the
console); speaking it inward to a child process is the same vocabulary. Every language that
has an MCP server library can write a tools bundle today with no mesh SDK at all, and the
mesh's own SDK for a language is a thin convenience over it. The transport stays in the
runtime, so a bus change rebuilds nothing.
4. **A protocol of the mesh's own design.** Rejected: a second way to describe a tool, its
schema and its call, inventing what MCP already settled, for no gain.
## Decision
**1. A module's own code is bundles, in any language the mesh has a toolchain for, and never an
image.** A `bundle` names its language and what it is for. Images are for third-party software a
module installs — a database, a forge — never for code the module wrote. One module may declare
several bundles: its tools, its implementation of a seat's verbs, a daemon, a step. Each is built
alone and delivered alone, as [ADR 0156](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)
already has it.
**2. A bundle the runtime serves is a process that speaks MCP over stdio.** The node's runtime
launches it as the bundle names it — an interpreter and a file, or a binary — with the runtime's
environment, asks `tools/list`, and answers each call on the bus by `tools/call`. A tool whose name
is `<seat>.<verb>` is the module's implementation of that seat's verb; any other name is the
module's own tool. Everything the runtime does with what it is told — subjects from the membership,
a held seat's verbs, the `tools` answer, a bundle that fails named and the others serving — stays as
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) and
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
have it. A TypeScript bundle may still be imported into the runtime's own process; that is a
shortcut over the same contract, not a second contract, and a TypeScript bundle written against
the protocol is served the same way as any other.
**3. A bundle that is a service is a `process`**, run by the host as a unit, in whatever language it
is compiled from, exactly as the host's own bundle already is. Nothing new is decided here; it is
said so that it is the rule and not an example.
**4. One thin SDK per language, and the test of ADR 0039 applies to each.** An SDK for a language
holds the MCP-over-stdio loop, the tool-definition type and the few primitives a module's code
needs; it holds no transport, no module's client and nothing volatile. Where a language has a
sound MCP library, the SDK wraps it rather than re-implementing it. The languages are those that
make sense to write a module in; the first set is TypeScript, Go, Python, Rust and C, and the set
grows when a module needs one, not before.
**5. Skeleton first.** Each piece — a toolchain, a launcher, an SDK — exists at the bare minimum
that lets one bundle in that language be built, delivered and answer one tool on the live mesh.
Anything beyond that is added when a module needs it. A skeleton that is not proven by one bundle
answering is not a skeleton; it is a promise.
## Consequences
- The runtime gains a launcher beside its loader. The loader, the memberships, the seats and the
failure handling built for ADR 0175 stand; the launcher is the one new step.
- The builder gains a toolchain per language, each at the skeleton: compile, pack, name the
entrypoint. Rust and C are new; a language that compiles to a binary says its operating system
as a Go bundle already does.
- An existing MCP server in any language is already a valid tools bundle. What the mesh adds is
the subjects, the seats and the memberships around it.
- The gate [to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP2 adds —
refusing a tools container built on the runtime's image — widens: a module whose own code is
an image artifact is refused at registration, naming this record.
- What got harder: a tools bundle is now a process per module on the node rather than code in
one process, so the runtime supervises children and restarts one that dies. The one-process
shape ADR 0175 counted on for the TypeScript shortcut remains available for it.
- ADR 0039's "what the SDK holds" now reads per language; its refusals are unchanged and are the
reason option 2 was rejected.
## How it is checked
| Rule | Checked by |
|---|---|
| A module's own code is never an image | the catalogue's registration check: a manifest with a `bundle` kind of own code *and* an image artifact built from the module's own directory is refused, naming this record |
| A tools bundle in a language other than TypeScript answers on the bus | the runtime's tests: a bundle written against the protocol in a second language, launched, its tool called over a real bus |
| A TypeScript bundle written against the protocol is served like any other | the same tests, with the TypeScript shortcut off |
| Each SDK is thin | each SDK's own README states what it holds under ADR 0039's test, and its size is in the mesh's records |
| Live | a tool in a compiled language answers from the node's runtime on one machine |
## References
- [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
[ADR 0039](0039-what-the-sdk-holds-and-refuses.md),
[ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md),
[ADR 0156](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md),
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
- [To-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) — the work packages this
record widens
- The Model Context Protocol's stdio transport — the local protocol a tools bundle speaks
@@ -1,130 +0,0 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
---
# 188. A provider declares what it derives for each consumer, and the mesh tells both ends
## Context
An arrangement between a consumer and a provider is delivered entirely by the mesh. Where the
provider is, which port it answers on, what name the consumer must present, where its password
is — each arrives as a fact the consumer reads from its binding, or as `${bound:…}` filled into a
file before the declaration leaves the control plane. The provider invents none of it and hands
none of it back ([ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)).
One kind of value escapes that. Where the **provider names the resource** — a bucket, a database,
a vhost — the name is derived from the consumer, per consumer, and the mesh has no way to carry
it. `serves` is a literal block in the provider's definition: the same values for every consumer.
A provisioner's contract takes a provision and returns nothing. So a value the mesh's own rule
produced reaches neither end as a statement; it is recomputed at one end and transcribed at the
other.
The object store is the instance ([issue 124](../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)).
Its provisioner normalises the login the mesh minted into a bucket name and creates, checks and
removes exactly that; the rule lives in twenty lines of the module's own TypeScript. Its three
consumers each write the answer into their own definition by hand. Two transcribed it correctly;
one named a predecessor's bucket, and would have authenticated successfully and been refused on
every object, which reads like a credential fault and is not one.
Even corrected, the transcriptions are wrong in a second way. Each is `mesh-<node>-<slug>`, so
each **names the machine the module happens to run on today** — a definition stating a fact about
one installation, which [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md)
forbids and whose check does not catch because the name is not a domain. Move any of the three to
another machine and its configuration points at a bucket its key cannot open.
The shape is not the object store's. A database provisioner that prefixed names, a queue provider
that scoped vhosts, any provider that derives a resource from who is asking: each forces the
consumer to reproduce somebody else's rule and keep it in agreement by hand.
## Decision
**1. A served value may name the consumer the mesh is serving.** A `serves` block, which is
literal today, may interpolate the mesh's own statement of who the consumer is:
- `${consumer:as}` — the identity the mesh minted for this consumer, exactly as the login it is
told to present ([ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md));
- `${consumer:as:dns}` — the same identity written as a DNS label.
Nothing else. **The mesh learns no protocol here; it spells its own name in an alphabet it already
knows.** The identity is the mesh's, minted by the mesh, already capped at twenty characters
because of what an S3 access key accepts; `dns` is that same name with its separator written `-`
instead of `_`, which is the whole of the difference between the mesh's identifier alphabet and
the one buckets, vhosts and hostnames use. A provider that needs a prefix or a suffix writes it
around the placeholder, because a served value is a string.
The rejected alternative is **the provider returning values from provisioning** — the natural
channel, since the provider is what derived them. It is rejected for three reasons, in order of
weight. It inverts the delivery the mesh is built on: a grant would carry data the provider wrote
rather than only data the mesh minted, and [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)
removed exactly that second path once already. It makes a consumer's declaration incomplete until
its provider's reconcile loop has run, so a consumer could not be composed before a provider
answered — a bootstrap order the mesh does not have and does not want. And it puts the rule where
nothing can check it: a value that arrives from a running process cannot be refused at resolution,
only discovered wrong later, which is the failure this record exists to end.
**2. The mesh resolves it once, per consumer, and tells both ends from the one resolution.** At the
moment a consumer's declaration is composed, the mesh knows exactly who the consumer is. There, and
only there, the placeholders are filled. The result reaches:
- the **consumer**, as the served facts in its binding file and as `${bound:<provision>:<key>}` in
any file it writes — unchanged mechanisms, carrying one more key;
- the **provider**, as `serves` on that consumer's entry in its contributions file, so the
provisioner is *told* the name rather than recomputing it.
**The provider stops deriving in code and starts declaring.** One statement, filled once, delivered
to both ends: the two cannot disagree, because there is no second computation to disagree with.
**3. A served value stays settled before it is per-consumer.** Settings still compose into `serves`
([ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)), and the consumer
placeholders are filled after that, so an operator may set a prefix and the mesh still derives the
rest. A `${consumer:…}` naming a fact or an alphabet the mesh does not have is refused when the
definition is parsed, with what it may say.
**4. A consumer may no longer name the resource its provider derives.** With the value delivered,
a literal in a consumer's definition is not merely redundant — it is the one thing that can
disagree with what the provider will actually create. The three object-store consumers lose their
hand-written bucket names in this change.
## Consequences
- One more thing a definition may say, and one less thing a module may be wrong about. The
vocabulary grows by a placeholder; the catalogue loses three literals that named this
installation's control node.
- A provider's naming rule becomes readable in its definition instead of in its source. `minio`'s
`bucketFor` goes; the manifest says `"bucket": "${consumer:as:dns}"` and the provisioner uses
what it is given.
- A provider that already serves consumers keeps serving them: the derived value equals what the
code derived, so no bucket, database or login changes name. This is a change of **who says it**,
not of **what is said**.
- The mesh now holds a rule in another system's alphabet — one rule, `dns`, stated once. A second
alphabet is a decision, not an addition: the cost of each is that the mesh must be right about
somebody else's naming, and that cost is only worth paying where the mesh already mints the name.
## How this is checked
- A served value naming an unknown fact or alphabet is refused at parse, with the list of what it
may say — tested on both halves of the message.
- Resolving a consumer whose provider derives a value puts that value in the consumer's binding
file, in its `${bound:…}` substitutions, and in the provider's contributions entry for that
consumer — one test asserting the three agree, because agreeing is the whole point.
- Two consumers of one provider on one machine get two different derived values, and neither gets
the other's.
- A catalogue-wide test refuses a consumer definition that writes a literal where its provider
derives: the provider's `serves` names the key, so the catalogue can say which definitions
transcribe one.
- `dns` is checked against the identity the mesh actually mints, not against an invented string:
the test derives an identity with `ConsumerIdentity` and asserts the label it becomes.
## References
- [issue 124 — a consumer cannot be told a value its provider derived for it](../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)
- [ADR 0048 — a provider creates the credential the mesh minted, and seals nothing](0048-a-provider-creates-the-credential-the-mesh-minted.md)
- [ADR 0049 — a consumer's identity fits the tightest backend](0049-a-consumers-identity-fits-the-tightest-backend.md)
- [ADR 0174 — a node varies a module through settings and kept regions, never through an edit](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
- [ADR 0155 — a definition names no installation, and how that is checked](0155-a-definition-names-no-installation-and-how-that-is-checked.md)
- [design 27 — a module requires, the mesh resolves](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md)
@@ -1,131 +0,0 @@
---
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)
+4 -2
View File
@@ -188,8 +188,6 @@ python3 00-META/checks/index.py fail if stale
- **0185** — [A control plane behind its seat's row serves what it can](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md)
- **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
@@ -280,6 +278,9 @@ python3 00-META/checks/index.py fail if stale
- **0150** — [A module's own code runs as supervised processes under the module's one account](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)
- **0152** — [The operator's surface is a module the mesh assigns: the console](0152-the-operators-surface-is-a-module-the-console.md)
- **0155** — [A definition names no installation: how that is checked, and the three ways a value that did gets out](0155-a-definition-names-no-installation-and-how-that-is-checked.md)
- **0164** — [A setting is declared with its default, its meaning and what changing it costs](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md) *(proposed)*
- **0165** — [`container-runtime` is what a machine can run; that a runtime is running is its holder's health](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md) *(proposed)*
- **0166** — [The container runtime is a node seat, and the host creates containers through its holder](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md) *(proposed)*
- **0173** — [The operator's machine is the mesh's, and a module is whatever it declares](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md)
- **0175** — [One tool runtime per node serves every module's tools, on the host side](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
- **0176** — [The login shell is a node seat held by one shell module, and `execute` is its contract](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
@@ -287,6 +288,7 @@ python3 00-META/checks/index.py fail if stale
- **0181** — [The operator account is a node fact, and a home is a placement root](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)
- **0182** — [Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
- **0183** — [The Anthropic licence manager is a module holding a seat; it hands each node's agent its token over the bus, sealed; the controller and the host have no part](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
- **0188** — [A module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
### How it is built
+1 -40
View File
@@ -5,10 +5,9 @@ code:
- mesh-controller cmd/mesh-builder
- mesh-controller internal/builder
- mesh-catalog modules/builder
updated: 2026-10-02
updated: 2026-10-01
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
@@ -294,44 +293,6 @@ 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 —
+1 -26
View File
@@ -5,12 +5,11 @@ code:
- mesh-catalog modules/showcase
- mesh-controller internal/builder
- mesh-sdk src
updated: 2026-10-02
updated: 2026-09-30
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
@@ -209,27 +208,3 @@ 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.
@@ -2,7 +2,7 @@
layer: to-be
status: in-progress
code: [mesh-controller internal/catalogue]
updated: 2026-10-02
updated: 2026-09-30
decisions:
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md
@@ -14,7 +14,6 @@ decisions:
- 02-DECISIONS/0084-which-provider-serves-a-consumer.md
- 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md
- 02-DECISIONS/0038-the-mesh-assigns-the-port.md
- 02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md
---
# 27 — A module requires, the mesh resolves
@@ -207,22 +206,6 @@ name when nothing sets it. That is the contract half of this design's operator p
the placeholder allows: the definition says which values reach which requirement, and nothing else
does. *How it is checked:* the unit tests named in issue 173, and the plan comparison that closed it.
*A provider says once what it derives for each consumer (2026-10-02,
[ADR 0188](../../02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md),
[issue 124](../../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)):*
where a provider **names the resource** it gives each consumer — a bucket, a database, a vhost — the
name is derived per consumer, and a literal `serves` block could not carry it. A served value may
now name the consumer the mesh is serving: `${consumer:as}`, the identity the mesh minted, and
`${consumer:as:dns}`, that same identity written as a DNS label. Nothing else — **the mesh learns no
protocol here; it spells its own name in an alphabet it already knows.** Settings are laid on first,
so an operator may still set a prefix and the mesh derives the rest. The mesh fills it at the one
moment it knows who the consumer is, and the one filled value reaches both ends: the consumer, as
its binding's served facts and as `${bound:<provision>:<key>}` in any file it writes; the provider,
as `derived` on that consumer's entry in its contributions file, so its provisioner is told the name
rather than recomputing it. A consumer that writes the derived value into its own definition instead
of asking for it is refused, naming the placeholder to use. *How it is checked:* the unit tests in
ADR 0188's "how this is checked", each run against the unchanged controller first.
## How a definition reads what was resolved
**One form, naming a requirement and a field of its contract.** A definition that needs the database's
@@ -231,9 +214,7 @@ name in a configuration file writes the same thing: the requirement's name and t
controller fills it at resolution.
This one form replaces the placeholders that exist today, one per mechanism: bound values, secrets,
ports and machine facts. It subsumes the consumer placeholder too — a value a provider derives is
read by the consumer exactly as any other field of the contract is, and `${consumer:…}` is only
how the *provider* states the rule.
ports and machine facts.
**The seat placeholder stays, for the controller alone.** The controller composes its own
declaration and reaches the store and broker it made before any module existed, so it cannot be
@@ -11,6 +11,7 @@ decisions:
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
- 02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md
- 02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md
---
# 38. Building the operator's machine
@@ -97,6 +98,19 @@ what `tools` answers, and the others serve. The runtime reads `MESH_OPERATOR_ACC
five tools and two seat verbs answer on their subjects; `tools` names the failed bundle; a
membership republished mid-run re-subscribes without a restart.
*Built and proven 2026-10-02* (mesh-tools, branch `feat/the-operators-machine`, commit `6390d1d`).
**WP1b — the launcher beside the loader** ([ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)).
*mesh-tools, mesh-sdk. A day for the skeleton.* A bundle whose entry is not JavaScript is launched
as a child process with the runtime's environment and spoken to over MCP on stdio: `tools/list`
once, `tools/call` per call; a tool named `<seat>.<verb>` is the seat's implementation. A child
that exits is named as a failed bundle and restarted on the next call. The TypeScript import stays
as the shortcut. Beside it, one skeleton SDK per language of the first set — the stdio loop and the
tool-definition type, nothing else — each proven by one bundle in that language answering one tool
in the runtime's test. **Proof.** The runtime's test: a bundle in a second language, launched, its
tool answering on its subject over a real bus; the TypeScript fixture served through the protocol
with the shortcut off answers the same.
## WP2 — The controller composes one runtime per node
*mesh-controller. Two to three days; the largest package.*
@@ -111,12 +125,23 @@ membership republished mid-run re-subscribes without a restart.
declaration gains an `archive` placed under a directory the controller derives, so the host
fetches and unpacks it as it does any artifact. The bundle's digest is what the build recorded.
3. **The runtime's process.** One `process` per node running the runtime from its own bundle
(WP3), `MESH_TOOL_MODULES` composed from the unpacked entrypoints, `MESH_OPERATOR_ACCOUNT` and
(WP3), `MESH_TOOL_MODULES` composed from the unpacked entrypoints — each as
`<module>=<path>`, and the runtime decides from the file whether it is loaded or launched
(WP1b) — `MESH_OPERATOR_ACCOUNT` and
`MESH_OPERATOR_HOME` from the account fact, `restart-on` naming every bundle so a push that
changes one restarts it. A node with no account composes the runtime without the two words.
4. **The gate.** A manifest declaring `tools` and a container built on the runtime's base image is
refused at registration once the runtime module is registered, naming this record. It is the
mechanism that keeps the old pattern from returning by habit.
mechanism that keeps the old pattern from returning by habit. ADR 0188 widens it, after WP4:
a module whose own code is an image artifact is refused, whatever image it is built on.
*Amended 2026-10-02, at WP3.* The gate refuses the pattern **spreading**, not standing: a module
new to the catalogue in that shape, or one that had already moved to a bundle and returns to it,
is refused; a module the catalogue already holds in that shape — judged from the manifest it
holds and what that module's newest build stood on — is rebuilt without complaint. The day the
runtime arrives some thirty such modules stand, each moves in its own change from WP4 on, and a
gate refusing every rebuild in the meantime would stop the catalogue's pipeline to make a point
this record already makes.
**Proof.** Composition tests: a node with three assigned modules, one holding a seat, yields one
process, three archives, one node principal whose grants are the union, and the same three
@@ -138,6 +163,16 @@ runtime's `serve` keeps answering MCP on loopback; the person's end of it keeps
wrote, `tools/list` on loopback answers as before, and the controller's verbs answer through it.
This is the first live step, and it is reversible by re-assigning `mesh-console`.
*Decided 2026-10-02:* `mesh-tools` keeps its name as the module the TypeScript images come from, and
`node-tools` is a second module in the same repository ([ADR 0069](../../02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md))
rather than a rename — the thirty-five manifests that build `on` `mesh-tools` stay true. Three things
WP3 found that the plan did not say: a TypeScript bundle must carry its dependencies and a
`package.json` naming its files as ES modules, which the toolchain now copies in from its own image;
the runtime's credential must be owned by the account the runtime runs as, which the controller
composes; and `MESH_TOOL_MODULES` is empty on a node where the runtime is the only bundle, which the
runtime accepts. *Built 2026-10-02* (mesh-tools `c46f950`, mesh-controller `ca7e81e` `773b561`
`729a5f9`); the live proof follows node by node.
## WP4 — The first holder moves: the packet filter
*mesh-catalog. Half a day. The live proof of ADR 0175.*
@@ -1,9 +1,9 @@
---
status: resolved
status: open
opened: 2026-09-23
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
located-in: []
fixed-by:
amended-design:
---
# 108 — The registry has no garbage collection, and two doors make it harder to add
@@ -75,32 +75,3 @@ 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.
@@ -1,9 +1,9 @@
---
status: resolved
status: located
opened: 2026-09-26
located-in: [mesh-controller internal/catalogue, mesh-sdk src/provisioner, mesh-catalog modules/minio]
fixed-by: 02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md
amended-design: 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
located-in: [mesh-controller internal/catalogue/declaration.go, mesh-sdk src/provisioner, mesh-catalog modules/minio]
fixed-by:
amended-design:
---
# 124 — A consumer cannot be told a value its provider derived for it, so it transcribes one
@@ -63,27 +63,3 @@ compares it to what the provider will actually create. The one wrong instance wa
- What would have caught the wrong instance? A test that resolves a consumer's grant and compares the
bucket in its own configuration against the one the provider would create is a check that could
exist today, for any interface, without the mechanism above.
## Answered, 2026-10-02 — [ADR 0188](../../02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md)
The channel is the provider's own `serves` block, which may now name the consumer the mesh is
serving: `${consumer:as}` and `${consumer:as:dns}`. The mesh fills it once, where it knows who the
consumer is, and delivers the one filled value to both ends — the consumer's binding and its
`${bound:…}` substitutions, and the provider's contributions entry, so a provisioner is told the
name rather than deriving it. Each open question above, answered:
- **Should a provider return values from provisioning?** No. It would make a grant carry data the
provider wrote, make a consumer's declaration wait on its provider's reconcile loop, and put the
rule where nothing can refuse it. The reasoning is in the record.
- **Or should `serves` say a value is derived?** Yes, and the mesh performs the derivation — but it
learns no protocol doing it. The only fact is the identity the mesh itself minted, in one of two
alphabets it already knows.
- **Should a consumer that names the resource be refused?** Yes. A consumer's file that already
contains the value the mesh is about to derive for it is refused at resolution, naming the
placeholder to write instead. That is the check this report asked for, and it is exact rather than
heuristic: a derived value carries the identity minted for this consumer on this machine, which
nothing else would spell out.
minio's `bucketFor` is gone; its manifest serves `"bucket": "${consumer:as:dns}"`. The three
consumers' hand-written bucket names are gone with it — each of them also named the machine the
module happens to run on, which is the second thing wrong with a transcription.
@@ -0,0 +1,85 @@
---
status: located
opened: 2026-10-01
located-in: [mesh-catalog modules/dnsmasq, mesh-controller internal/overlay/generator.go, mesh-controller internal/catalogue/resolve.go (checkResources)]
fixed-by:
amended-design:
---
# 190 — The container runtime's configuration is written by modules that are not the runtime's
## What was observed
The runtime's configuration file and its service are declared by two parties, neither of which is
the runtime:
- **The resolver module** writes the runtime's `dns` key (the machine's private address) and
`live-restore` into the runtime's file, written into rather than over
([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md)). It also
declares the runtime's service, reloaded when that file changes. The `dns` key has been written
since the resolver module was converted from its predecessor on 2026-09-23; `live-restore` and the
service were added on 2026-09-30 while fixing
[issue 110](../110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md),
where containers silently resolved through a public resolver.
- **The private network** writes the runtime's `insecure-registries` into the same file, and declares
the same service reloaded on it, as [ADR 0082](../../02-DECISIONS/0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md)
and ADR 0102 decided. The controller generates both resources per machine.
On the three machines that run the resolver module, both declare one path and one unit. Nothing refuses
it. The collision check compares the resources of catalogue modules. The private network is computed,
so its resources are produced when a machine's declaration is composed, and the check never sees them.
The machine without the resolver module shows the other half. Its runtime still has the predecessor's
resolver and `live-restore` off, because the only module that sets them is a DNS server. A machine
gets a correct container runtime only as a side effect of being given a resolver.
> **Later the same day, 2026-10-02.** The resolver module and its sibling for the resolver file were
> assigned to the fourth machine ([issue 198](../198-the-lans-dns-server-ran-outside-the-mesh-and-its-filter-closed-it/00-report.md)),
> so all four now have the resolver writing into the runtime's file, and the predecessor's
> `live-restore: false` there is gone. The same work made the runtime's file, as the resolver declares
> it, take no settings: a setting meant for the resolver's own configuration had reached it. The
> collision and the ownership question above are unchanged.
## Why this is here
The operator ruled it a defect, not a design: **a module does not write another software's
configuration.** The need behind each write is real. Containers must resolve the mesh's names
([ADR 0148](../../02-DECISIONS/0148-the-meshs-names-are-resolved-not-copied-into-containers.md) step 2).
A daemon restart must not stop every container. Every machine on the network must trust the mesh's
registry. But each of these is a fact the runtime must be *given*, and the module that gives it is the
runtime's own. With three writers, nobody can say what the file should contain. Two of the facts are
reloaded when one of them needs a restart (issue 110's first fault). And the moment a module for the
runtime exists, it is refused on every machine with the resolver, or, through the private network's
path, accepted without anyone noticing a collision.
## What resolves it
[ADR 0166](../../02-DECISIONS/0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)
gives the runtime a module that holds its seat and owns its file and service.
[ADR 0164](../../02-DECISIONS/0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md)
gives that module declared settings with defaults. The fix, once both are accepted:
1. The resolver module drops its runtime file and runtime service. It knows nothing of the runtime.
2. The private network stops generating either resource. ADR 0082's decision stands — being on the
network is what grants the trust, and no module author is involved — and only *who writes it*
moves. The mesh gives the registry to the runtime module as a value. ADR 0082 and ADR 0102 each
get a dated note saying where their mechanism now lives.
3. The runtime module writes `dns`, `live-restore` and `insecure-registries`, each a declared
setting with its cost: `dns` costs a restart, which `live-restore` makes harmless.
4. Steps 1–3 land in one push. A runtime module declaring the file beside a resolver module still
declaring it is refused.
5. The collision check sees a computed module's resources as well, so a second writer cannot come
back through generated code.
## Open questions
- **How the resolver's address reaches the runtime.** Either the resolver seat (`node-dns-resolver`)
delivers an address its holder serves, or the runtime module reads a machine fact and the seat
being held is only a precondition. The first tracks a resolver moving off the private address. The
second needs nothing new.
- **What `dns` defaults to on a machine with no resolver seat held.** Nothing, leaving the runtime's
own behaviour, is the honest default. A public resolver hides exactly the failure issue 110 took a
day to find.
- **The adopted machine's predecessor values.** The runtime module adopting a file with a
hand-written `dns` and `live-restore: false` replaces both. That is intended, and is the one
restart the operator must make on that machine.
@@ -0,0 +1,78 @@
---
status: open
opened: 2026-10-02
located-in: [mesh-catalog modules/mesh-console, mesh-controller cmd/mesh-controller/plan.go (port assignment)]
fixed-by:
amended-design:
---
# 192 — The mesh's tools reach a person only by a registration made by hand
## What was observed
A design session on a workstation had none of the mesh's tools. The console was running on that
machine and answering on its loopback port. It was reached over the bus as the console's account, and
listed every running module's tools and every seat's verbs
([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)). What was missing
was the registration that tells the person's coding agent where the console is. That registration had
been made by hand, once, while migrating the machine, and scoped to the one project directory it was
made in. Every session started anywhere else had no mesh tools. Nothing said so: the agent simply
offered no mesh tools, and the session fell back to a pull-request link for a person to open by hand.
The predecessor did this job itself: it wrote its tool server into the agent's user configuration on
every machine. Migrating removed that entry, as it should have, and no module took the job over.
## Why this is here
Three gaps, each of which would have stopped a module from doing it even if one existed.
**1. The console tells nobody where it is.** Its definition listens on a port and provides nothing.
A module that wanted to point an agent at the console has no requirement it could name, so it
would have to write the address into its own definition as a literal. That is exactly what
[ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) and
[ADR 0155](../../02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md)
remove.
**2. The console's port is one its definition chose.** The definition names a port, and the mesh
never assigned one: no port assignment exists for the console on any machine. The plan assigns a
machine port only to a port a container publishes through a mapping, "without one the software binds
what it binds". The console runs on the host network with no mapping, but it reads its listening
address from `${port:…}`, so the mesh could move it and does not. That is a module choosing a
machine port, which [ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md) exists to
prevent, through a gap in how the rule is applied rather than a decision against it. A module that
reads its port from the mesh should be assigned one like any other.
**3. Nothing in the mesh owns a person's agent configuration.** No catalogue module writes the agent's
settings, its tool-server registrations, or the rules and skills the predecessor delivered. On the
four machines these are hand-kept, or left over from the predecessor, or missing.
## What a fix looks like (not decided)
- **The console provides its endpoint.** A provision, working name `mesh-tools`, served as the URL on
the machine port the mesh gives it. The console listens only on loopback, so the provider must be on
the consumer's own machine. Co-location already chooses it
([ADR 0084](../../02-DECISIONS/0084-which-provider-serves-a-consumer.md)), and a machine with no
console refuses the consumer, naming the provision.
- **A module for the coding agent requires it** and writes the registration into the agent's
system-wide managed settings. The agent reads tool servers from a `managedMcpServers` key there. That
file is the machine's rather than a user's, so the module owns it whole and no home directory is
named. People keep their own registrations beside it. The agent's separate *exclusive* managed
file is the wrong one: it blocks every registration a person makes and hides the hosted connectors.
The agent's per-user file is rewritten by the agent continuously and sits in a home directory,
which would make its path an operator value. These facts come from the agent's documentation
(managed MCP and managed settings pages), not yet verified on a machine.
- **The same module owns the rest of the agent's configuration** the predecessor delivered: managed
settings and the rules, skills and instructions every session reads. Each declared setting carries
a default (ADR 0164,
proposed on its own branch), so one configuration serves every machine and one machine may differ.
## Open questions
- **Is the agent's configuration one module or several?** Tool registration, managed settings, and
the instruction files have different readers and change at different rates.
- **Whose machine port is the console's?** Should a host-network container that reads its port from
`${port:…}` be assigned one, or should a machine-only listener keep its declared number? The second
needs a decision, because ADR 0038 does not allow it today.
- **Credentials.** The console's authority is the machine's login (ADR 0152). A registration that
reaches it carries no secret today. If the console ever listens beyond loopback, the registration
needs one, from the vault.
@@ -0,0 +1,112 @@
---
status: resolved
opened: 2026-10-02
located-in: [mesh-catalog modules/postgres/client.ts (readOnlyQuery)]
fixed-by: [mesh-catalog PR 209 (postgres), mesh-catalog PR 210 (mssql)]
amended-design:
---
# 193 — The store seat's read-only query is read-only by convention, and its answer is unreadable
## What was observed
Asking the store seat's `query` verb for a count through the console returned this. Rows are each
wrapped in an object under a key named `BEGIN`: the column name, then the value, then the word
`ROLLBACK`. A query returning nothing gave the column name and `ROLLBACK` alone. The answer to
`select count(*) as n from <table>` was:
> `rows: [ {BEGIN: "n"}, {BEGIN: "46"}, {BEGIN: "ROLLBACK"} ]`
A reader can work it out. A program cannot, and a query with two columns loses which value belongs to
which.
## Why this is here
**The cause is the same line that makes the query read-only.** The holder's tool sends
`BEGIN TRANSACTION READ ONLY; <the caller's statement>; ROLLBACK;` to the command-line client as one
string. The client prints a command tag for each of the three statements, and the parser takes the
first line, `BEGIN`, as the header.
**And it is not read-only.** [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
decided the store seat's `query` verb is "one read-only statement against one database". The only
thing enforcing that is the wrapping transaction, and the caller's statement is pasted inside it as
text. A statement that begins by ending the transaction (a commit, then anything) runs whatever
follows it outside the read-only transaction, with the holder's own role, the administrative one that creates every
consumer's role and database. A rule stated in a decision and enforced by string concatenation is enforced by nothing.
*This is read from the code, not tried against the live store, and it should not be tried there.*
The lab bed is where it gets proven.
Every caller with `invokes` on the store seat's `query` can do this. The console has `invokes: ["*"]`,
so that includes anyone logged in on a machine running the console.
## What a fix looks like
- **One statement, refused otherwise.** Send the caller's statement alone, through the client's
single-statement path (the extended protocol takes one statement per call and refuses more). The
read-only property then comes from the session, not from text around the statement.
- **Read-only by role, not by transaction.** Run the verb as a role that can only read, granted
`pg_read_all_data`, not as the administrative role. A statement that escapes every wrapper still cannot write.
- **Rows as rows.** Parse the client's output with the column names it returns, or use a driver
instead of the command-line client, so a row is an object keyed by its columns.
- **The check 0159 lacks:** a test that sends a commit followed by a write and asserts the write is
refused and nothing changed. Another asserts a two-column row comes back keyed by both columns.
## Proven, 2026-10-02
On a throwaway server — the same engine image, no network, reached over a socket — the module's code
from the catalogue's main branch ran `COMMIT; COPY (select 1) TO PROGRAM '<a command>'` and **the
command ran on the database host** as the server's own user. `COMMIT; DROP TABLE t` executed the drop
outside the read-only transaction; the wrapper's own trailing rollback happened to undo it, which a
caller ending their statement with a commit of their own would get past (not tried). Nothing was tried
against the live store.
The fix (mesh-catalog PR 209) runs the caller's statement as a login granted `pg_read_all_data` and
nothing else, read-only by its role and its session, with a password the mesh mints as one of the
module's own secrets; without that password the call is refused rather than run as the admin. On the
same throwaway server every escape above, and `SET ROLE`, `RESET SESSION AUTHORIZATION`, turning
read-only off, creating a table, altering the role and reading a server file, is refused; a plain
select comes back keyed by its columns. One attempt — turning the transaction's read-only off, then
deleting — got past the first layer and was stopped by the second, which is why both exist.
**Not answered by the statement-count fix proposed above.** The command-line client sends one string
in one message, so several statements still arrive together. They are harmless as the reader, and
refusing them is left to whoever moves the module to a driver.
## The same hole, elsewhere — and two worse ones
The `mssql` module wrapped a caller's statement the same way (`BEGIN TRANSACTION; … ROLLBACK;` as its
administrator) for its `mssql_query` tool. Its command-line client added two holes of its own. Both
were proven on a throwaway server, running the client the way the module ran it:
- **It substitutes `$(NAME)` from its environment into the caller's text**, and the administrator's
password is in that environment. Selecting it as a string returned the password.
- **It reads a line beginning `:!!` as a command that starts a program**, in the container that holds
the administrator's password and the module's bus credentials. Its switch for refusing such commands
makes the shipped version ignore the statement entirely, so the switch cannot be the guard.
None of it was reachable on the live mesh, for a reason that is a defect of its own: the runtime image
never installed the client, so every mssql tool failed (`spawn sqlcmd ENOENT`). The fix (mesh-catalog
PR 210) installs the client at a pinned digest and runs the caller's statement as a login that can
connect and read and do nothing else. Substitution is off. The statement must be one line, placed after
the module's own text, so no line of it can begin a command; a line break is refused before the client
starts. On the throwaway server, writes, `xp_cmdshell`, impersonating the administrator, and joining
the administrators' role were all refused, and the variable came back as the literal text.
**The general lesson**, worth more than either module: *a command-line client is an interpreter with
its own syntax, and a caller's text handed to it is a program in that syntax as well as in SQL.* A
module that passes a caller's text to a client has two languages to defend, and a transaction drawn
around the text defends neither.
## Resolved, 2026-10-02
Both pull requests merged, built and pushed to the two machines that run each module. Checked live, on
every copy, by asking each one who it is:
- the store seat's `query`, and postgres's own tool on each machine, answer as the reader login —
not a superuser, in a read-only transaction — with rows keyed by their columns;
- mssql's tool, on each machine, answers as its reader login, outside the administrators' role, and
returns `$(SQLCMDPASSWORD)` as the literal text it is. Its tools work for the first time.
The escapes themselves were tried only on the throwaway servers above; on the live mesh the check is
the identity a statement runs as, which is what makes every escape a statement that the login cannot do.
@@ -1,67 +0,0 @@
---
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.