Compare commits

..
Author SHA1 Message Date
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 3d54fcbb86 Merge remote-tracking branch 'origin/main' into decision/docker-module 2026-10-02 00:02:57 +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
15 changed files with 504 additions and 422 deletions
-16
View File
@@ -131,22 +131,6 @@ def main():
else:
seen[number] = name
# And decision records, which 155's fix left out: on 2026-10-02 two ADRs numbered 0169 landed
# on main from two sessions within the hour, and every check passed.
seen_records = {}
for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))):
name = os.path.basename(path)
number = name.split("-", 1)[0]
if not number.isdigit():
continue
if number in seen_records:
bad(os.path.join("02-DECISIONS", name),
"is numbered %s, and so is %s -- a record's number is how it is cited. Take the next "
"free number across main AND every open pull request; the branch that lands last "
"renumbers" % (number, seen_records[number]))
else:
seen_records[number] = name
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
front = frontmatter(path)
if front is None:
@@ -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)
@@ -113,22 +113,6 @@ That is a difference a take shows, not a fault, and is decided when it bites.
| `node show` lists filters with owners; `status` names a converged machine something else filters and is not well; the preview lists filters and fates | controller tests over a fixture report |
| Live | the home server's record names the predecessor's chain in the runtime's user chain as *other*; `status` names the machine; after the operator removes the chain, the next report drops it and `status` is well |
## Built and proven live, 2026-10-02
> **Progressive insight — 2026-10-02.** The decision stands; these are the facts of its building.
Built in mesh-host 67 (every refusing table and legacy chain classified with an owner, reported with
every apply; the found firewall retired on every converged apply, *found inactive* kept apart from
*disabled by the mesh*, a skipped step said) and mesh-controller 211 (kept per node, shown on `node
show`, named by `status` and not well, previewed with fates). The live row was read at 10:10Z: the home
server's record named the predecessor's chain in the legacy filter's user chain as *other*, beside two
chains a retired front end left in the IPv6 legacy filter; the control node's record named the same two
leftovers; the laptop and the workstation read *the mesh alone*; `status` named both machines. The five
rule sets were removed at 12:46Z through the packet filter seat's `remove` verb
([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)), and the next report read *the mesh alone* on
all four machines. The control node's record still says the mesh retired its front end, which issue 143
records as a hand's work: the host trusts its record, and from this build on the distinction is kept.
## References
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [ADR 0103](0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md), [ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0163](0163-taking-a-module-over-is-a-comparison.md)
@@ -1,89 +0,0 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0004-a-node-and-how-it-joins.md
---
# 169. A machine joins through the tunnel, and the bus is never public
## Context
The bus is the one channel every machine depends on: enrolment, every declaration, every tool. The
`nats` module declares it reachable from the mesh only. The controller still opens it to the whole
internet on the machine that runs it, as a *foundation* port that no module declares and nothing may
close ([issue 051](../04-ISSUES/051-the-mesh-cannot-update-what-it-depends-on/00-report.md)).
The reason is joining. [ADR 0004](0004-a-node-and-how-it-joins.md) has a new machine enrol over the bus
**before** it has a tunnel. [ADR 0007](0007-connectivity.md) states it as a requirement: the node
running the broker must be reachable from wherever nodes are, at a stable address.
So the bus listens on the internet permanently, for an event that happens a few times a year. A
sweep of every machine on 2026-10-02 found no client using the public path. Every connection arrives
over the tunnel or from the machine itself. The join token does not use it either: it carries the
controller's configured broker address, a mesh name with the old broker's port.
ADR 0004 already says what a joining machine needs: *an identity, an address, and one peer to reach*.
The tunnel can be that peer, if the hub knows the new machine's key before the machine first knocks.
WireGuard answers nothing to a key it does not know, which is why the tunnel's own port is safe to
leave open where the bus's is not.
## Considered Options
1. **Keep the bus public.** It is authenticated and encrypted, but every exposure of it, and of the
server behind it, is exposure of the one thing everything depends on.
2. **Open the bus publicly only while a join token is live.** Small, and the hub is open only during a
join window. But the window is real, the rule is about time rather than about who may reach the
bus, and the opening and closing are pushes that can fail between them.
3. **The controller makes the new machine's tunnel key and puts it in the token.** One step for the
operator, but the private half leaves a machine it does not belong to. ADR 0004 refuses that for
every key a node holds.
4. **The machine makes its key first, and the token is issued for it.** The machine prints the public
half of its tunnel key. The operator issues the token for that key. The controller gives the
machine its address and adds it as a peer on the hub. The token carries the hub's tunnel endpoint
and key, the machine's address, and the bus's address on the private network. The machine brings
up its tunnel and enrols over it.
## Decision
**Option 4.**
- **A machine makes its own tunnel key before it has a token**, and prints the public half. The private
half never leaves it, as ADR 0004 says of every key a node holds.
- **A token is issued for a tunnel key.** Issuing it assigns the machine's address on the private
network, records the key, and makes the machine a peer of the hub. The hub is sent that before the
token is shown, so the tunnel answers the moment the machine first uses it.
- **The token carries the one peer.** It adds the hub's tunnel endpoint and public key and the
machine's own address. **Where** becomes the bus's address on the private network, which needs no
name resolution.
- **The machine joins through the tunnel.** It brings the tunnel up from the token alone, then enrols
over it exactly as before. The enrolment checks that the key it is offered is the one the token was
issued for.
- **The bus is never public.** It is no longer a foundation port. Its reach is what the `nats` module
declares: the mesh. The tunnel's port stays open, as the one way in.
This changes three things earlier records say. ADR 0004's *where* is the bus's private address, and the
token carries the peer. ADR 0007's requirement that the broker be reachable from wherever nodes are
becomes: **the hub's tunnel is**. Issue 051's broker port stops being a foundation port.
## Consequences
- Joining is two commands on the new machine, with the token issued between them. A token issued for
the wrong key gives a tunnel that never answers, and the machine says so rather than timing out at
the bus.
- An unused token leaves a peer on the hub until it expires. Expiry removes it, the same way it voids
the secret.
- A machine already in the mesh is unaffected: it reaches the bus over its tunnel today.
- The genesis machine, the first one, raises the bus on itself and needs no tunnel to reach it.
## How this is checked
| Rule | Checked by |
|---|---|
| A token is refused without a tunnel key, and carries the hub's peer and the machine's address | a controller test |
| Issuing a token makes the machine a peer of the hub before the token is shown | a controller test over the hub's composed tunnel |
| An expired, unused token's peer is gone from the hub | a controller test |
| Enrolment refuses a tunnel key other than the one the token was issued for | a controller test |
| No machine's filter opens the bus to anywhere | a controller test over the composed filter, and the live sweep from outside the mesh |
| A new machine joins from outside the hub's network with the bus closed to it | the lab, then by hand |
@@ -1,103 +0,0 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
---
# 170. The firewall seat serves its verbs, and a foreign rule set is removed through one of them
## Context
[ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md) made the mesh say truthfully
what filters a converged machine, and left the removal of what it did not write to the operator's
hand. The first time that hand was needed — two machines, five rule sets a predecessor and a
retired front end had left — there was no mesh way to lend it: the packet filter is a seat
([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)), a seat's
holder serves its verbs ([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md),
[ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)), and the
firewall seat declared none. The only remaining path was a shell on the machine, which is the path
the mesh exists to replace, and which the operator's own tooling rightly refused to an agent.
A seat's verbs are the contract every holder implements, whatever filter it speaks. What a person
asks a machine's packet filter is the same whether nftables, a front end or a legacy filter answers:
what are the rules, reload the mesh's own, remove this thing the mesh did not write. What differs by
filter is the holder's own business and may be its own tools beside the seat's.
## Decision
**1. The `node-packet-filter` seat serves three verbs**, and a module that claims it serves all
three or is refused the claim, as with every seat:
- `rules` — the packet filter as the machine enforces it now: the nftables ruleset, and the legacy
filter's listings where that tool exists; narrowed to one table or chain when asked. Read-only.
- `reload` — load the mesh's own filter again from the file the mesh writes, and answer with the
mesh's table as loaded. The holder's own act on the holder's own rules.
- `remove` — remove one rule set the mesh did not write, named exactly as the host reports it under
ADR 0168 (`chain HAL-MESH-ONLY (iptables-legacy)`, `table ip6 filter, chain DOCKER-USER`), and
answer with what was done. It refuses the mesh's own tables, the container runtime's own chains,
a built-in chain other than the runtime's user chain, and any chain of a found firewall that is
in force. The runtime's user chain is emptied back to its one return; another chain loses the
jumps into it, is flushed and deleted; a table of the machine's own is deleted whole. Each is an
operator's act, by name, on one thing the mesh reported — never a flush, never a rule the mesh
itself marked.
**2. A holder may serve its own tools beside the seat's.** The nftables module keeps its reading of
the mesh's table as its own tool, and a holder speaking a filter with specifics of its own may add
tools for them; the seat's three are what every holder owes.
**3. A container may ask for a capability.** Serving `remove` and `reload` needs the machine's
network namespace and the right to change its packet filter; a holder's runtime declares
`capabilities: ["NET_ADMIN"]` on its container and runs on the machine's network. The host grants
exactly the capabilities declared, names them in the container's spec so a change recreates it, and
refuses a name that is not a capability's. A privileged container stays undeclarable.
**4. ADR 0168's "by hand" is read as "by the operator, through the seat".** Removing what the mesh
reports as *other* is still the operator's act and is still never the mesh's own doing; the verb is
how the act reaches the machine, recorded on the bus like every other, instead of a shell.
## Consequences
- The seat's row gains the three verbs; a mesh that already runs widens its row at the next
controller start. The nftables module claims them and gains a runtime — a tool server with the
packet filter's tools in its image, on the machine's network, with `NET_ADMIN`.
- The host's container vocabulary grows by `capabilities`; an older host refuses a declaration that
carries it, so the host rolls before the module.
- The two machines of this mesh that ADR 0168 found not filtered by the mesh alone are cleaned
through `remove`, and read *the mesh alone* afterwards; `status` returns to well without a hand on
either machine.
## How this is checked
| Rule | Checked by |
|---|---|
| The seat declares the three verbs; a claim that serves fewer is refused by name | the catalogue's seat tests |
| `remove` refuses the mesh's tables, the runtime's chains, a built-in chain and an active front end's chains, and removes a user chain with its jumps, empties the user chain, deletes an own table | the module's tests over a fake command runner, with the shapes the host reported live |
| A container's capabilities reach the runtime and its spec; an unknown name is refused | host tests |
| Live | `node-packet-filter.remove@<node>` on the home server and the control node; `node show` reads *the mesh alone* on both; `status` is well |
## Built and proven live, 2026-10-02
> **Progressive insight — 2026-10-02.** The decision stands; these are the facts of its building.
> Written as 0169 for three hours and renumbered to 0170: another record took 0169 on main first,
> and the check that refuses a shared number covered issues only (now records too).
Built in mesh-host 68 (`capabilities` on a container), mesh-controller 212 (the seat's three verbs)
and 213 (the filter file a module names under `filtering.into` counts as declared for a mount — the
module's first build was refused without it), mesh-catalog 216 (the nftables module's runtime and
verbs) and mesh-tools 27 (the console lists a node-scoped seat's verbs with their scope and carries the
machine; before it, the verbs were live on four machines and unreachable from the console —
[issue 199](../04-ISSUES/199-a-node-scoped-seats-verb-could-not-be-called-through-the-console/00-report.md)).
Each machine's holder was issued its bus account with `mesh-controller.issue`, the broker node pushed
first. At 12:46Z the five rule sets ADR 0168 had named were removed through
`node-packet-filter.remove`, three on the home server and two on the control node, each answering
with the commands it ran; the next report read *the mesh alone* on all four machines and `status`
listed nothing under `filtered`. The live row is read. What it cost on the way is
[issue 200](../04-ISSUES/200-the-controllers-answer-to-the-console-is-refused-by-the-bus/00-report.md).
## References
- [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)
- [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md), [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md)
@@ -1,59 +0,0 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0016-the-lab.md
---
# 172. The lab is a module, and runs a bed when the mesh asks
## Context
The lab raises virtual machines and runs the mesh on them, end to end, before a change reaches a real
machine ([ADR 0016](0016-the-lab.md)). It runs on one machine of the mesh, the one with the
virtualisation it needs. Until now the only way to start a bed there was to sign in to that machine and
run the lab's command line by hand, with a dozen environment variables pointing at sibling checkouts.
Nothing in the mesh could ask for it. An agent working through the mesh's own tools could build,
merge and push a change, and could not prove it in the lab first. The operator's direction on
2026-10-02: work on another machine goes through a mesh tool, not a shell on it.
## Considered Options
1. **Keep the lab a command line on one machine.** Every run is a person, or an agent with a shell on
that machine, outside the mesh.
2. **The lab is a module.** Assigned to the machine that can run it, serving tools that run a bed
against named branches and say how it went.
## Decision
**Option 2.**
- **A `lab` module, assigned where the lab can run**, serves five tools: whether this machine can run
beds, run beds against a branch per repository, a run's state, its log, and stopping it.
- **A run is the lab's own suite**, against fresh checkouts of the named branches from the mesh's forge,
side by side as the lab expects them. It builds what the beds place from those checkouts, as the suite
already does. It answers at once with an id, like a build: a bed takes minutes, and a call does not.
- **Only branches on the forge are run**, never code handed to the tool. What a run tested is what the
forge holds at the commit it names.
- **The lab is reached over the mesh only.** Its tools travel the bus, and the module opens no port.
- **No grant beyond the mesh's own.** Running a bed is root on the lab's machine, but anyone who can call
the mesh's tools can already do worse. The operator's judgement on 2026-10-02.
## Consequences
- An agent proves a change in the lab through the mesh, the same way it builds and pushes one.
- The lab's machine carries a module whose runtime holds the virtualisation's and the container
runtime's sockets, and a toolchain to build the mesh with.
- A run's checkouts are its own, so two runs never build from each other's tree. Old ones are removed
when their run ends.
## How this is checked
| Rule | Checked by |
|---|---|
| A run checks out exactly the named branches, and reports the commits it tested | the module's tests over a forge fixture, and each run's answer |
| A run answers at once, and its state and log follow it to the end | by hand, the first run |
| The module opens no port | the composed filter of the lab's machine |
+3 -3
View File
@@ -179,9 +179,6 @@ python3 00-META/checks/index.py fail if stale
- **0163** — [Taking a module over is a comparison: what it compares, what it refuses, and what it carries](0163-taking-a-module-over-is-a-comparison.md)
- **0167** — [A membership carries what its module receives, and who the mesh is](0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md)
- **0168** — [A converged machine is filtered by the mesh alone, and the host says what else refuses](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)
- **0169** — [A machine joins through the tunnel, and the bus is never public](0169-a-machine-joins-through-the-tunnel-and-the-bus-is-never-public.md)
- **0170** — [The firewall seat serves its verbs, and a foreign rule set is removed through one of them](0170-the-firewall-seat-serves-its-verbs.md)
- **0172** — [The lab is a module, and runs a bed when the mesh asks](0172-the-lab-is-a-module-and-runs-a-bed-when-the-mesh-asks.md)
### Its tiers, from the bottom up
@@ -272,6 +269,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)*
### How it is built
+3 -22
View File
@@ -1,10 +1,9 @@
---
layer: to-be
status: in-progress
code: [mesh-lab, mesh-catalog modules/lab]
updated: 2026-10-02
code: [mesh-lab]
updated: 2026-09-11
decisions:
- 02-DECISIONS/0172-the-lab-is-a-module-and-runs-a-bed-when-the-mesh-asks.md
- 02-DECISIONS/0016-the-lab.md
- 02-DECISIONS/0010-delivery.md
---
@@ -120,25 +119,7 @@ In order, on a machine with nothing:
6. **Verification**, as above, before anything is raised.
## The lab answers the mesh
*2026-10-02* ([ADR 0172](../../02-DECISIONS/0172-the-lab-is-a-module-and-runs-a-bed-when-the-mesh-asks.md)).
Once installed, the lab is also a module: `lab`, assigned to the machine that passed `check`. Its
tools run there and nowhere else:
| tool | does |
|---|---|
| `lab_check` | the lab's `check`, on this machine |
| `lab_run` | fresh checkouts of the named branches from the forge, side by side, then the suite on the named beds; answers with an id |
| `lab_status` | where a run is, and how it ended: the commits it tested, passed and failed |
| `lab_log` | the run's output so far |
| `lab_stop` | ends a run |
The runtime is a container holding the toolchain the suite builds with. It reaches the
virtualisation daemon and the container runtime through their sockets on the machine, so what it
raises is what a hand run raises. The prerequisites above stay installed by hand. The module uses
them, and never installs them.
## Open
- **Whether the lab's bootstrap may install packages at all**, given that the mesh's rules
forbid installing by hand. The resolution is probably that the lab's bootstrap *is* the
-29
View File
@@ -9,8 +9,6 @@ code:
- mesh-host internal/apply (the service that reflects a rule set)
updated: 2026-10-02
decisions:
- 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md
- 02-DECISIONS/0169-a-machine-joins-through-the-tunnel-and-the-bus-is-never-public.md
- 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
- 02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md
- 02-DECISIONS/0148-the-meshs-names-are-resolved-not-copied-into-containers.md
@@ -175,28 +173,6 @@ the broker's node must be dialable by every node, at a stable address, and so mu
reachable; on one network it does not. A mesh whose nodes are all behind NAT cannot be raised, and
a broker node whose address moves invalidates every token issued for it.
*2026-10-02.* **The order changes at step 1: the tunnel comes first, from the token**
([ADR 0169](../../02-DECISIONS/0169-a-machine-joins-through-the-tunnel-and-the-bus-is-never-public.md)).
The circularity above is real, and it is broken differently. The overlay is configured by the mesh,
except for the one peer a joining machine needs, and the token carries that peer. So the sequence
becomes:
```
0 the node has an underlay address the machine's own
1 the node makes its tunnel key before any token; it prints the public half
2 a token is issued for that key its address assigned, and the hub sent it as a peer
3 the tunnel comes up to the hub from the token alone: the hub's endpoint and key, its address
4 the node dials the bus OVER THE TUNNEL, at the bus's private address
5 it proves itself, and is proved to enrolment, checking the key is the one the token named
6 the rest of the overlay the whole peer set, delivered as files
7 names, filtering, routes as before
```
The link no longer stays on the underlay. The bus is reached over the tunnel by every machine,
including one that is joining, so it is never opened to the internet. The precondition becomes: **the
hub's tunnel must be dialable by every node, at a stable address.** That port answers nothing to a
key it does not know.
**Whether the link should later move onto the overlay, with the underlay as fallback, is
[open](../../02-DECISIONS/0007-connectivity.md).** It is a decision rather than a derivation: the
gain is which network carries bytes, not what an attacker can reach, since the link is already
@@ -767,11 +743,6 @@ tests over a fixture report check the recording, the preview's fates, the status
predicate. Live: the home server's record names the predecessor's chain as *other* and `status`
names the machine until the chain is removed by hand.
*2026-10-02, [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md):* removing what
the host reports as *other* is reached through the packet filter seat's `remove` verb, an operator's act
by name on the bus; the seat also serves `rules` and `reload`, and its holder's runtime declares the
`NET_ADMIN` capability on the machine's network. See design 33.
## 5 — Certificates
**Two authorities, kept separate on purpose.**
@@ -2,9 +2,8 @@
layer: to-be
status: implemented
code: [mesh-controller, mesh-tools]
updated: 2026-10-02
updated: 2026-10-01
decisions:
- 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
- 02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md
- 02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md
@@ -170,18 +169,6 @@ either way.
What stays as designed and not built: which verbs any *other* seat serves, and §3 for module-declared
seats' schemas beyond the names their manifests already list.
## The firewall seat's verbs, 2026-10-02
[ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md). The first node-scoped seat
to carry verbs: `node-packet-filter` serves `rules` (the filter as the machine enforces it, nftables
and legacy), `reload` (the mesh's own filter from its file) and `remove` (one rule set the mesh did
not write, named as the host reports it under ADR 0168; refusing the mesh's tables, the runtime's
own chains, a built-in chain and an active found firewall's). Every holder serves all three; the
nftables module does so from a runtime on the machine's network with `NET_ADMIN`, which is the first
container to declare a capability. Removing a predecessor's rule set is an operator's act reached
through the seat, recorded on the bus, instead of a shell on the machine. *How it is checked:* ADR
0169's table.
## What this does not settle
- Which verbs each seat should serve. That is a decision per seat, and the reason to do it slowly: a
@@ -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.
@@ -1,35 +0,0 @@
---
status: resolved
opened: 2026-10-02
located-in: [mesh-tools src/client.ts (toolsOn left node-scoped seats out of the listing), mesh-tools src/mcp.ts (the call resolved the key against that listing)]
fixed-by: mesh-tools 27 — node-scoped seats are listed with their scope, the verb requires the machine, and the call carries it in the subject
amended-design:
---
# 199 — A node-scoped seat's verb could not be called through the console
## What was observed
2026-10-02, the first time a node-scoped seat declared verbs
([ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md)). The packet filter's
holder on every machine served `rules`, `reload` and `remove` on the seat's per-machine subjects, and
the bus admitted them. The console answered every call with *nothing serves
node-packet-filter.rules@<machine>*.
[Design 33 §4](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) says a node-scoped seat's
tool carries the node it is asked of, as `<seat>.<verb>@<node>`. The console's listing left
node-scoped seats out — the comment said they *wait for a caller naming the node* — but the roles map
the console resolves a name against is built from that same listing. So the name never resolved as a
seat's verb, fell through to a module's subject nobody served, and the refusal named the wrong cause.
## Why it matters beyond this instance
A stated behaviour that did not happen, with a refusal that pointed elsewhere: the seat's verbs were
live on four machines and unreachable from the one surface a person uses. It could only be found by a
node-scoped seat declaring verbs, which none had.
## Resolved, 2026-10-02
mesh-tools 27: node-scoped seats are listed with their scope, their verbs take a required `node`, the
call carries it in the subject, and a call without one is refused in words. Tested with a round trip
asking one machine's holder and being refused without a machine.
@@ -1,36 +0,0 @@
---
status: open
opened: 2026-10-02
located-in: []
fixed-by:
amended-design:
---
# 200 — The controller's answer to a long console call is refused by the bus
## What was observed
2026-10-02. A `push` of the control node asked through the console came back as *mesh-controller.push
did not answer in time. Something is serving it, so this is the tool being slow rather than absent.*
The push had run; the machine applied. The bus's log on the control node, at the same moment:
```
[ERR] 10.10.0.1:56030 - cid:4015 - Publish Violation - User "controller",
Subject "_INBOX.shanks.mesh-console.WRNO5V9IJ1FX35AU5NRBEB.WRNO5V9IJ1FX35AU5PN1UW"
```
The controller's reply to the console's request was refused: the controller's bus account may not
publish to the console's reply inbox. Shorter calls (`status`, `node`, `nodes`) answer; the long ones
(`push` of a large machine, `issue`) time out on the console's side although they succeed.
## Why it matters beyond this instance
A call that succeeds and is reported as a timeout sends a person to retry what already happened — a
second push, a second issue — and reads as the mesh being slow when it is the mesh refusing itself.
Whether the inbox prefix the console uses is the one the controller's account is allowed to answer, or
the request outlives the inbox subscription, is what a diagnosis has to tell apart.
## Open questions
- Which reply inboxes may the controller's account publish to, and which does the console request on?
- Does a reply after the requester's timeout count as a violation, or is the prefix itself refused?