Compare commits

..
Author SHA1 Message Date
mesh-admin d57289e049 Merge pull request 'ADR 0186: a ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang' (#299) from fix/a-ban-list-never-holds-a-neighbour into main 2026-10-02 16:43:32 +00:00
jschoubben d4a2f99ab5 ADR 0186: a ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang; design 31 2026-10-02 18:42:16 +02:00
mesh-admin a9f91fdd0c Merge pull request 'ADRs 0184 and 0185: a service is still running a moment later; a control plane behind its row serves what it can' (#298) from fix/a-service-asked-to-run-is-still-running into main 2026-10-02 16:25:44 +00:00
jschoubben 131a5e4714 Issue 201: what was established about the race while closing the outage half 2026-10-02 18:19:12 +02:00
jschoubben 329a24fdae ADRs 0184 and 0185: a service is still running a moment later; a control plane behind its row serves what it can; issue 201 half closed 2026-10-02 18:16:24 +02:00
mesh-admin 7f72f3b79a Merge pull request 'ADR 0179: the intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail' (#295) from feat/the-intrusion-seat-serves-its-verbs into main 2026-10-02 15:28:48 +00:00
jschoubben 114a71f36f ADR 0179: built and proven live; the one fault the machine found, and the check that refuses it 2026-10-02 17:28:38 +02:00
jschoubben 0f407417f3 Merge main: the ufw record renumbered to 0180, and ADR 0175 retires the per-module tool runtime this one ships 2026-10-02 17:28:23 +02:00
mesh-admin 4af731df19 Merge pull request 'The operator's agent and its licence manager are modules: ADR 0181–0183, to-be 36 and 39' (#286) from feat/claude-code-module into main 2026-10-02 15:23:38 +00:00
jochen 7b1dabbce0 The operator's agent and its licence manager are modules: ADR 0181–0183, to-be 36 and 39
The predecessor's agent module was retired and its six files stayed on both workstations telling
every session to use tools that no longer exist. This is its successor's design, revised during
review on the operator's directions: the host is module-agnostic, the controller has no part, and a
real licence manager hands out the correct licence in every situation.

- ADR 0181 (reconstructed): the operator account is a node fact stated by the operator; the home is
  derived unless stated; a resource may be placed under it owned by the account; a node with no
  account refuses one. What the controller shipped on 2026-09-27 without a record.
- ADR 0182: inside a home the module owns the directory and the files it places, writes into the
  tool's own files for its few keys, never declares a credential's content, and holds everything
  else as found; a predecessor's leftovers are the operator's to remove once.
- ADR 0183: claude-licence-manager holds the mesh seat anthropic-licence-manager and owns the
  Anthropic licences, grants (encrypted with a key the vault made for it), bindings per touchpoint,
  usage and audit; one rotation source under a lease; a token travels module to module sealed to
  each node's module key on request/reply, never as an event; the agent module alone writes what
  the agent reads; the host delivers package and state and knows nothing else. A bounded exception
  to ADR 0113; dated mechanism notes on ADR 0050 and 0113.
- To-be 36 (claude-code): the mesh's part of the agent's configuration lives in the agent's
  machine-wide managed directory, owned whole by the module and written by its code; nothing under
  the home but the credentials file of a subscription licence; the API-key licence through the
  key-helper; the console as a node-scoped provision (to-be 34 amended); MCP servers as settings
  with an mcp_configure tool; one agent directory per machine shared by every session.
- To-be 39 (claude-licence-manager): store, the two licence kinds, keeping a grant alive, the
  hand-over, who gets which licence with the predecessor's fallbacks, adoption with the identity
  guard, the seat's verbs.

Numbers taken across main and every open branch at the time of the merge; to-be 14, 29 and 34
carry dated notes; the glossary gains "operator account".
2026-10-02 17:22:12 +02:00
mesh-admin 2caa5e827b Merge pull request 'To-be 38: building the operator's machine as work packages; ADR 0175 collision renumbered to 0180' (#296) from feat/the-operators-machine into main 2026-10-02 15:20:38 +00:00
jochen 179fd7f83f To-be 38: building the operator's machine as work packages; ADR 0175 collision renumbered to 0180
The runtime work of design 37 broken down the way design 28 broke down the
bus: what exists measured (the host needs no change — a process and an
archive are what the runtime and a bundle are; the runtime does the job for
one module and must do it for a list), the order the dependencies allow, and
eight packages each ending at a proof on the live mesh — the lab skipped by
the operator's decision, ADR 0149 cited. WP1 the runtime serves many modules;
WP2 the controller composes one per node (a node principal, bundle archives,
the runtime's process, the gate); WP3 the runtime is a module and the console
its serving mode; WP4 the packet filter moves first; WP5 the shell on a
server; WP6 the service manager on a workstation; WP7–8 named and not broken
down.

Main carried two records numbered 0175 and cycle.py refused it: the one that
landed last (the found front end is uninstalled) takes 0180, the next free
across main and the open changes, with a dated note; design 08's citation
follows it; the index is regenerated.
2026-10-02 17:20:14 +02:00
jschoubben d0d5799884 Issue 201: a push recreated the controller at a digest older than the seat row its successor wrote 2026-10-02 17:15:22 +02:00
jschoubben 9ba4de5557 ADR 0179: the intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail; designs 31 and 33 2026-10-02 17:02:49 +02:00
mesh-admin e1203e5a43 Merge pull request 'Research 019: a warm twin of the running mesh' (#294) from jschoubben/research-019 into main 2026-10-02 14:59:44 +00:00
jschoubben 4ce967619a Research 019: a warm twin of the running mesh in the lab 2026-10-02 16:59:36 +02:00
mesh-admin 6df2cfecd6 Merge pull request 'Research 018 graduates: ADRs 0173–0177 and to-be 37, the operator's machine' (#293) from feat/the-operators-machine into main 2026-10-02 14:58:48 +00:00
jochen 73047501f6 ADR 0154: the controller seat gains a generic command verb (dated note, by ADR 0175) 2026-10-02 16:53:15 +02:00
jochen bf39baf104 Research 018 graduates: ADRs 0173–0177 and to-be 37, the operator's machine
Every configurable thing on a node is a module, the home included, and a
module is whatever it declares (0173, extending 0040). A node varies a module
only through a setting rendered into the file or a kept region, never an edit
(0174, extending 0011; issue 168 first). One tool runtime per node serves every
module's tools on the host side, never in a container; the console is its
serving mode, renamed node-tools (0175, extending 0150; 0047/0150/0152 carry
dated notes). The login shell is a node seat held by one shell module with
`execute` as its contract (0176). A unit may be user-scoped and the service
manager is a node seat held by systemd (0177).

To-be 37 is handed off in-progress to mesh-host, mesh-controller, mesh-tools
and mesh-catalog, with the build in order: the account on every node, the
runtime, zsh, systemd, then the graphical stack. To-be 29 keeps ~/.ssh and
points at 37; 33 §6 and 34 are amended; the glossary gains node tools, bundle,
kept region, installed/holding, and retires flavor.
2026-10-02 16:34:57 +02:00
mesh-admin 5eadf36937 Merge pull request 'ADR 0175: the found front end is uninstalled once a machine is converged' (#292) from feat/the-found-front-end-is-uninstalled into main 2026-10-02 14:29:00 +00:00
jschoubben 8c9a2c7501 ADR 0175: the found front end is uninstalled once a machine is converged; design 08 note 2026-10-02 16:27:33 +02:00
mesh-admin 54213ba90c Merge pull request 'Research 018: the operator's machine as modules' (#291) from research/018-the-operators-machine into main 2026-10-02 14:27:27 +00:00
jochen 7fb59bde98 Research 018: the operator's machine as modules
The predecessor is retired on every node and what it still owned on the two
workstations — some thirty modules of dotfiles, user units and /etc files — is
owned by nothing. To-be 29 covers one directory under the home; the operator
wants the whole machine, system folders and home alike, as modules: one
default configuration each, varied per node by settings or a kept region,
never an edit; roles the machine has once as node-scoped seats with tool
contracts; the graphical stack gated by a capability so the same catalogue
serves the servers.

Four documents: the intended behaviour in the mesh's words; the predecessor's
desktop measured (34 modules, one with 88 files, 4 flavors and ~90 theme
variables) against what the records already give and what is missing (the
account is empty on every node, no user-scoped units, settings leak, tools run
in a container per module per node); the direction the operator set for where
tools run — one executor per node, host-side, module-agnostic, the console
renamed and moved out of its container, superseding 0047/0150 for tools; and
the candidate seats of the environment with first verbs, the shell first.
2026-10-02 15:19:25 +02:00
mesh-admin a4d24d7b65 Merge pull request 'ADR 0168: built and proven live' (#290) from decision/0168-built into main 2026-10-02 13:09:22 +00:00
jschoubben 116b2d1793 ADR 0168: built and proven live — the live row read on the home server and the control node, the five rule sets removed through ADR 0170's verb 2026-10-02 15:08:58 +02:00
mesh-admin 68a14493c9 Merge pull request 'ADR 0169 → 0170: the firewall seat's record renumbered after a collision on main; built note; cycle.py refuses two records sharing a number' (#289) from decision/0170-renumbered-and-built into main 2026-10-02 12:52:17 +00:00
jschoubben 98eb3aa76f ADR 0169 → 0170: the firewall seat's record renumbered after a collision on main; its built note; cycle.py refuses two records sharing a number
Another session's 0169 landed first. The collision check from issue 155
covered issue folders only; it covers decision records now, and would have
refused this.
2026-10-02 14:51:22 +02:00
mesh-admin 91bbe648a8 Merge pull request 'Issues 199 (resolved: a node-scoped seat's verb through the console) and 200 (the controller's answer to a long console call is refused by the bus)' (#288) from issues/199-200-console-seat-verbs-and-inbox into main 2026-10-02 12:25:37 +00:00
jschoubben 0e7b85f184 Issues 199 (resolved: a node-scoped seat's verb through the console) and 200 (the controller's answer to a long console call is refused by the bus) 2026-10-02 14:25:03 +02:00
mesh-admin dac49de6e7 Merge pull request 'ADR 0172: the lab is a module, and runs a bed when the mesh asks' (#287) from jschoubben/the-lab-is-a-module into main 2026-10-02 12:17:21 +00:00
jschoubben 0d9208dbbf ADR 0172: the lab is a module, and runs a bed when the mesh asks 2026-10-02 14:15:44 +02:00
mesh-admin bf0ee7cb25 Merge pull request 'ADR 0169: the firewall seat serves its verbs, and a foreign rule set is removed through one of them' (#285) from feat/the-firewall-seat-serves-its-verbs into main 2026-10-02 11:29:15 +00:00
jschoubben 4567e13071 ADR 0169: the firewall seat serves its verbs, and a foreign rule set is removed through one of them
Designs 33 and 08 revised. The first node-scoped seat with verbs: rules,
reload, remove; the nftables module holds it from a runtime with NET_ADMIN,
the first container to declare a capability.
2026-10-02 13:27:03 +02:00
mesh-admin aa5d9f1045 Merge pull request 'ADR 0169 (proposed): a machine joins through the tunnel, and the bus is never public' (#284) from jschoubben/a-machine-joins-through-the-tunnel into main 2026-10-02 11:17:42 +00:00
jschoubben 79642251a1 ADR 0169 accepted; design 08's join order starts with the tunnel 2026-10-02 13:17:32 +02:00
jschoubben 331cb94c6e ADR 0169 (proposed): a machine joins through the tunnel, and the bus is never public
The bus was public only so a new machine could enrol before it had a
tunnel. The machine now makes its tunnel key first, the token is issued
for it and makes it a peer of the hub, and enrolment happens over the
tunnel.
2026-10-02 13:13:15 +02:00
mesh-admin 78351560f7 Merge pull request 'Issue 198: the home network's DNS server ran outside the mesh, and its filter closed it' (#283) from jschoubben/the-lans-dns-is-the-mesh into main 2026-10-02 10:22:51 +00:00
mesh-admin 62cc2f89c7 Merge pull request 'Issue 197: a physical link that is down is not filtered when it comes up' (#280) from jschoubben/every-physical-link-faces-outside into main 2026-10-02 10:22:40 +00:00
jschoubben 426f741ad0 Issue 198: the home network's DNS server ran outside the mesh, and its filter closed it 2026-10-02 12:22:31 +02:00
jschoubben 9bed54d3be Issue 197 resolved: the wired port is guarded before it is plugged in 2026-10-02 12:22:15 +02:00
jschoubben 413daf8ad5 Issue 197: a physical link that is down is not filtered when it comes up 2026-10-02 12:22:15 +02:00
mesh-admin 5c993c09b7 Merge pull request 'Issues 143 and 144 resolved: the live row of ADR 0168 read on the home server and the control node' (#282) from issues/143-144-resolved into main 2026-10-02 10:11:37 +00:00
jschoubben 7c3be48db2 Issues 143 and 144 resolved: the live row of ADR 0168 read on the home server and the control node 2026-10-02 12:11:17 +02:00
mesh-admin b665d06701 Merge pull request 'ADR 0168: a converged machine is filtered by the mesh alone, and the host says what else refuses (group 7)' (#281) from feat/one-thing-filters-a-converged-machine into main 2026-10-02 10:03:48 +00:00
jschoubben 1bd13446d4 ADR 0168: a converged machine is filtered by the mesh alone, and the host says what else refuses (group 7)
Designs 08 and 05 revised; 141 resolved by ADR 0140 and 084 by ADR 0102 and
issue 128, both by reading; 143 and 144 decided, built on the matching
branches in mesh-host and mesh-controller, resolved when the home server's
record names the predecessor's chain.
2026-10-02 11:58:18 +02:00
mesh-admin 14adaafa53 Merge pull request 'to-be 29: the account fact and home-scoped resources shipped; the ssh-client module, CA and ~/.ssh boundary did not' (#279) from design/29-what-shipped into main 2026-10-02 09:49:52 +00:00
mesh-admin bd673cc6ec Merge pull request 'Group 6 closed: 086, 090, 098, 099, 100, 101 resolved on the operator's decision' (#278) from issues/group-6-closed into main 2026-10-02 09:38:11 +00:00
jochen 8c231102f8 to-be 29: the account fact and home-scoped resources shipped; the ssh-client module, CA and ~/.ssh boundary did not
The controller merged §1, §2 and the composed ssh config on 2026-09-27 while hq still
called the design proposed. Record what is on main, what is held on a branch, and what
has no code — and that the account fact shipped without a decision record, which is the
next thing to write. Also drop the real account and node names the public repository must
not carry, and correct ADR numbers that drifted in a renumbering.
2026-10-01 23:14:10 +02:00
56 changed files with 3631 additions and 38 deletions
+16
View File
@@ -131,6 +131,22 @@ 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:
+23
View File
@@ -9,6 +9,11 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
- **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is
just a machine that has joined; being one implies nothing about what it runs.
- **operator account** — the login name of the person who works on a node, stated on the node
record; empty for a machine nobody logs into. Everything the mesh places under a person's home is
resolved against this account's home and owned by it
([ADR 0181](../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
Not "the user" (ambiguous with a module's own account) and not a name a definition carries.
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one
per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs
the controller (and, today, the foundation). Lose it and the other nodes keep running what they
@@ -95,3 +100,21 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
A new name for an existing thing lands here first, in the same change that introduces it in code. A
record under `02-DECISIONS/` keeps whatever word it was written with — those are immutable — so a
term retired here may still appear there, and the mapping above is how to read it.
## The operator's machine
- **node tools** — the one tool runtime per node, a host-side process the host supervises, that loads
every assigned module's tools bundle and serves every tool and held seat's verb on the subjects the
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.
- **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)).
One of the two ways a node varies a module; the other is a **setting**.
- **installed / holding** — a module may be assigned (its package installed, its files placed) without
holding the seat its family declares; *holding* is being the one — the login shell, the display
session — on that node ([ADR 0176](../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)).
- ~~flavor~~ — not used. What a flavor varied is a setting or a separate module.
@@ -0,0 +1,86 @@
---
status: graduated
initiated: 2026-10-02
touches:
- 02-DECISIONS/0040-what-a-module-is.md
- 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
- 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
- 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
- 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
- 02-DECISIONS/0161-what-deserves-a-seat.md
- 03-DESIGN/01-to-be/05-the-node-host.md
- 03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md
- 03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md
- 03-DESIGN/01-to-be/34-the-console.md
- 03-DESIGN/00-as-is/10-module-catalogue.md
- 04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
became:
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
---
# 018 — The operator's machine as modules
**What.** The mesh owns the whole machine, not only the services on it. Everything a person
configures on a node — the login manager, the display server, the window manager, the shell, the
terminal, the launcher, the notifier, the audio setup, the boot images, the downloads folder, the
agent at the terminal — is a module: a package, the files it owns under `/etc` and under the
operator's home, the seat it holds, the tools it serves. One default configuration per module,
varied per node only through settings rendered into the file or a kept operator region, never
through an edit. The servers take the universal modules (shell, prompt, git, the agent); the
workstations take those and the graphical stack, which a capability the machine reports gates.
This effort writes that behaviour down, measures what the predecessor's desktop modules actually
contain, and settles what the mesh must gain before the first of them can be written.
**Why.** The predecessor is retired on every node. What it still owned on the two workstations —
about thirty modules' worth of dotfiles, user units and `/etc` files — is now owned by nothing:
no generator regenerates them, and a fix to one of them is a hand edit that nothing records. The
migration scoped these modules out as *the workstation's own environment*, and
[to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) names them as the last
thing the predecessor was keeping alive. To-be 29 covers one directory, `~/.ssh`, and draws a
boundary inside it. The operator wants no boundary: the machine is the mesh's, as far as it makes
sense to configure it. That is a wider scope than any design states, and it reaches three records
that were written for services: what a module is, where a module's tools run, and what a managed
file may be.
**What it touches.** The module definition ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)),
seats and their contracts ([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)),
where a module's tools run ([ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md),
[ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md),
[to-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §6), the host's vocabulary
([to-be 05](../../03-DESIGN/01-to-be/05-the-node-host.md)), managed files and settings
([ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md),
[issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)),
and the catalogue's shape ([as-is 10](../../03-DESIGN/00-as-is/10-module-catalogue.md)).
**Documents.**
- [01 — The intended behaviour](01-the-intended-behaviour.md): the operator's wish, written as
how the mesh behaves, in the mesh's own words.
- [02 — What exists, and what is missing](02-what-exists-and-what-is-missing.md): the
predecessor's desktop modules measured; which records already say what is wanted; the gaps.
- [03 — One tool executor per node](03-one-tool-executor-per-node.md): where a module's tools
run. The direction the operator set, the evidence for it, and what it supersedes.
- [04 — The seats of the environment](04-the-seats-of-the-environment.md): the roles a machine
has once, their candidate contracts, and what gates each.
**What it had to settle, and where each landed.** *(Graduated 2026-10-02.)*
1. A module is one *managed thing*, software or not, and every module may serve tools — or ADR
0040 already says this and only its examples are narrow.
2. One tool executor per node, host-side, module-agnostic; which records it supersedes and
in what form the console continues.
3. Per-node variation is a setting rendered into the file or a kept region, never an edit —
ADR 0011 stands — and issue 168 is fixed before any environment module carries a setting.
4. User-scoped units on the host's `service` shape, and a service-manager seat whose holder
serves the tools about them.
5. The operator account stated on every node; today no node record carries one.
6. The seats of the environment and their verbs, one record per seat, slowly, because a
seat's tools bind every future holder.
7. Where the environment modules live: this catalogue, or one of their own as the media chain
has; and whether a third-party organisation's tooling belongs in a public catalogue at all.
@@ -0,0 +1,99 @@
# 01 — The intended behaviour
*Written 2026-10-02 from the operator's words, in the mesh's words. What is wanted, before what
exists. Where a sentence restates a record, the record is named; where it goes further, that is
said.*
## The machine is the mesh's
**Everything configurable on a node is declared by a module.** Not only the services the mesh
runs: the login manager, the display server, the window manager, the bar, the launcher, the
notifier, the compositor, the lock screen, the terminal emulator, the clipboard, the shell and its
prompt, the editor, the audio setup, the boot images, the package manager's configuration, the
agent a person runs at a terminal, and the folders a person works in — a downloads folder that is
tidied, backed up, distributed to other nodes and asked questions of. System folders and the
operator's home alike. The operator is the only person on every node, so the mesh manages the
person's machine, not a machine with a person on it.
This is [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)'s definition applied without
the service bias its examples carry. A module is one managed thing, named once, described
completely by its manifest. It may have a package, files, a container, a unit, a binary, a seat it
holds, and tools it serves — any one of these, or all, or two. There is **no kind of module**: zsh
has a package, files, a seat claim and the tools that claim obliges it to serve; downloads has a
folder, a process and tools; nftables has a package, files, a service, a seat and tools. The
difference is what each declares, not what each is.
**The home has no boundary.** [To-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)
owns one directory under the home and draws a line inside it between the mesh's and the person's.
Here the line is drawn only by what the modules declare: every file some module places is the
mesh's; what no module declares is found and left alone, exactly as the adoption rules already
say for a machine. The reach is bounded by sense, not by a rule — the mesh configures what can
be configured, and a person's documents, projects and history are data under
[ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md), not configuration.
**A module names no node and no path.** The operator account is a node fact and the home is
derived from it ([to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2,
shipped in the controller; its record is proposed in an open change). A module places a file
*under the home, owned by the account*, and the same manifest lands on a server and a laptop.
## One default, varied by settings, never by edits
**One module, one default configuration.** The window manager module ships the configuration
that is right for every node. There are no flavors: the predecessor's one desktop module carried
four, one per class of machine, and what differed between them is what settings are for.
**A node varies a module in exactly two ways.** A **setting**, declared by the module with its
type, meaning and default (proposed alongside the container-runtime records), set for the mesh
or for one node, and rendered into the file at composition — the value is in the file, not in an
environment variable the file reads. Or a **kept region**: a block in a file the mesh writes
*into*, where the operator's own lines survive every push
([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md)). An
edit to a managed file outside such a region is not a third way; it is overwritten, as
[ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) says, and the
predecessor's habit of adopting disk drift back into its database is not carried over.
The predecessor's theming — some ninety environment variables substituted into templates at sync
time, with tools to list and set them — is the same idea with the wrong rendering. The knobs
become declared settings; the file carries the value.
## Roles a machine has once are seats, and seats carry tools
**A role a machine fills at most once is a node-scoped seat**, declared by a module
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md),
[ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)): the login shell, the
display session, the display server, the terminal emulator, the launcher, the notifier, the
compositor, the lock screen, the service manager, the boot loader. Several modules may be able to
hold one — zsh, fish and bash can all hold the login shell — and the assignment on each node says
which does. Installing a shell is installing software; holding the seat is being *the* shell.
**A seat's contract is its tools** ([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)).
Every holder of the login-shell seat serves `execute`, which takes one string, the command, and
runs it on the node the seat is scoped to. Every holder of the boot seat serves "rebuild the boot
images", so *"rebuild your boot images"* is a verb addressed to a machine, not a one-off step in
a hook. Every holder of the service-manager seat answers for the units on the machine, system and
user scope. A module may serve its own tools beside the seat's
([ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §2): show the rendered
configuration, set a theme value, report status.
**Any tool may be called from any node.** The operator's statement, and the grant model it
implies: the executor on each node may call everything, as the console already may. A verb that
needs root on the machine is the module's concern — the tool escalates, the executor and the
caller do not know.
## Servers and workstations differ by capability, not by catalogue
The same catalogue serves every node. A module declares what it needs — a graphical session, a
display server, a container runtime — and the machine reports what it has, as the profile already
reports eight capabilities today ([issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)).
Assignment refuses the wrong placement by name
([ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3). So every node takes the shell,
the prompt, git and the agent; only a node with a graphical session can take the display server,
and only a node holding the display server can take a window manager. Nothing in a module says
"workstation".
## What the operator would say to the mesh
*Set the login shell on the build node to fish. Rebuild the laptop's boot images. Show me the
window manager's effective configuration on the desktop and where each value comes from. Give
the downloads folder on the laptop to the home server. Run `uptime` on every node.* Each of these
is a seat verb or a module tool, addressed to a node, answered by whatever holds the role there.
@@ -0,0 +1,91 @@
# 02 — What exists, and what is missing
*Measured 2026-10-02 on one installation: two workstations, two servers, all four converged to
the mesh; the predecessor retired on the last workstation the day before. Numbers are from the
machines and the repositories, not from memory.*
## 1. What the predecessor's desktop looks like
The predecessor's catalogue on the laptop held **34 modules**, of which **28** are the operator's
environment rather than services. By what they declare:
| shape | count | examples |
|---|---|---|
| package only | 9 | browser, mail client, process monitor, media player, file manager, chat |
| package + `/etc` files + system service | 5 | login manager, display server, power and thermal daemons, package manager configuration |
| package + files under the home | 6 | shell and prompt, the agent at the terminal, scripts, the sync client, a music player |
| files under the home + user units + hooks | 2 | the desktop environment, audio |
| third-party organisation tooling | 6 | out of scope here |
**The desktop module alone** declares **88 files**, **4 flavors** (the window-manager stack, and
one per class of machine), **2 user units** with a hook to enable them, 8 files under `/etc`, a
wallpaper shipped as an asset, and reads **about 90 environment variables** as theme knobs,
substituted into its templates at sync time and set through a theming tool. Its hook exists
because *shipping a unit file does not run it*: one unit had been deployed for months and ran on
one machine only, because somebody had enabled it there by hand.
**The shell module** ships `~/.zshrc`, the prompt configuration, an `~/.ssh/config` that the
predecessor generated from its registry, and a `LOGIN_SHELL` variable applied with `chsh` by a
hook. Two flavors: the prompt theme, and autocompletion.
**Other modules write into the desktop module's files.** The chat client places i3 and notifier
snippets into `config.d` directories the desktop module owns, and its launch flags, window
placement and notification colours are each a variable with a default.
**One-off steps live in hooks** across the set: enable user units, `chsh`, create a swap file,
`mkinitcpio`, enable a vendor VPN service the package ships disabled. Every one is state the
host could declare or a verb a seat could serve; none is today.
## 2. What the migration did with them
The migration's module to-do scoped the whole set out as *desktop / workstation ricing — the
workstation's own environment* and *node/OS tooling — managed on the node, never catalogue*. The
last workstation's runbook then split the same set three ways: **A**, system scope, which the
host's vocabulary can express today (the login manager, the display server, the power daemons,
the package manager, the container runtime); **B**, under a home or a user unit, waiting on
to-be 29; **C**, package only, the operator's call. The migration log closes the workstation with
*the operator's desktop awaiting its design*.
Two things followed from scoping them out. Nothing regenerates those files now, so a fix is a hand
edit — the login manager's session script was fixed this way on the day of writing, and recorded
in a repository nothing deploys from. And the one piece of this family written as a mesh module,
the ssh client, was closed on hold in the catalogue until the controller carried the account fact.
## 3. What the records already give
| wanted | record | state |
|---|---|---|
| one module per managed thing; every module may have tools | [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) | accepted; examples are services, and the shell is named as a *shared* seat |
| a module declares its own node-scoped seat | [ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) | accepted |
| a seat's contract is its tools; a holder may add its own | [ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) | accepted; one node seat serves verbs live |
| a capability the machine reports gates a holder | [ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3 | accepted; the profile already reports `graphical-session` |
| the account as a node fact; a file under the home owned by it | [to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2 | built in the controller; its record proposed in an open change |
| inside a home: owned, written into, written by the module, found | proposed in the same change | proposed |
| a setting declared with type, meaning, default and cost | proposed with the container-runtime records | proposed |
| a managed file is derived; an edit is overwritten | [ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) | accepted |
| the mesh writes into a shared file, never over it | [ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md) | accepted |
| a module names no path; the host resolves the home | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) | accepted |
| the `user` shape: a login shell is declared state | [to-be 05](../../03-DESIGN/01-to-be/05-the-node-host.md) | designed; used by no module |
## 4. What is missing
1. **The account is recorded nowhere.** The node record has the column; on all four nodes it
is empty. Every home-scoped module is unassignable until the operator states it.
2. **User-scoped units.** The host's `service` shape has no user scope. To-be 29 says it
plainly: *a workstation's per-user daemons have no form the mesh can send.* The desktop
module's two units, the audio masks, the power module's memory guard and the thermal
daemon's profile switcher all need it.
3. **One-off steps.** `mkinitcpio`, `chsh`, creating a swap file. Each is either declared
state the host lacks a shape for, or a verb a seat should serve. An action in a declaration
is refused over the link, and rightly.
4. **Settings leak** ([issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)):
a setting reaches every mergeable file and every contribution of its module. Ninety theme
knobs on that mechanism would reach ninety files. The proposed settings record says a setting
names the file it lands in; that has to ship first.
5. **Where tools run.** Every module that serves a tool today does so from its own container
per node. See [03](03-one-tool-executor-per-node.md).
6. **A seat's verbs are undecided for every seat but three.** To-be 33 leaves which verbs each
seat serves as *a decision per seat, slowly*. The environment adds a dozen seats.
7. **Catalogue placement.** The media chain left this catalogue for its own; whether the
environment does the same, and whether a third-party organisation's tooling belongs in a
public catalogue, are unasked.
@@ -0,0 +1,88 @@
# 03 — One tool executor per node
*The direction the operator set on 2026-10-02, the evidence it rests on, and what it supersedes.
A direction, not yet a decision: the record is written when this effort graduates.*
## Where tools are served today
[To-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) names three families — a
role's tools on the seat, a module's own tools on the module, the mesh's own verbs on the
controller seat — and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
says a runtime serves the subjects its membership issues. What *runs* that runtime is
[ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md):
one supervised process per module, under the module's own account, carrying that module's
compiled tools. Measured on the live mesh:
| who answers | how it runs | count |
|---|---|---|
| the mesh's own verbs | the controller binary, on its node | 17 verbs |
| the store seat | the store's own runtime | 2 verbs |
| the packet-filter seat | **a container per node**, built on the tool-runtime base image, with the network namespace and `NET_ADMIN`, on all four nodes | 3 verbs and 1 own tool |
| every module's own tools | the module's container, one per node it runs on | 67 tools across the catalogue |
| the console | a container per node, loopback MCP, `invokes: *` | serves none, calls all |
| the host | — | serves nothing; answers no question about the machine |
**The packet-filter holder is the case to look at.** The module is a package, three files and a
system service. To serve three verbs it also declares a built image and a container on every
node whose only job is to answer them. Scaled to the environment — a shell, a prompt, a launcher,
a notifier, a compositor, a login manager, a service manager, a boot loader, a downloads folder —
that is one container per module per node for software that is itself not a container, and the
operator's judgement is that tools should not run inside a container at all.
## The direction
**One tool executor per node, on the host side.** A process the host supervises, the way the
launcher supervises the host ([ADR 0005](../../02-DECISIONS/0005-the-node-host.md)): not a
container, one bus credential for the node, module-agnostic. It loads the tool code of every
module assigned to the node and serves each module's tools and each held seat's verbs on the
subjects the membership issues — nothing changes in what [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
say about subjects, grants and memberships; what changes is that one process subscribes for the
node instead of one per module.
- **A module brings its tools as a built artifact**, a bundle the pipeline produces, never an
image. The executor knows bundles and subjects; it knows nothing of zsh or nftables.
- **A tool is code the module wrote**, one function behind an MCP verb. `execute` on the shell
seat is a function with a string argument. The executor does not declare, template or
interpret tools; it runs them.
- **Root is the module's concern.** A tool that must change the packet filter or rebuild boot
images escalates itself. The executor does not run as root for everyone, and the caller does
not know.
- **Any node may call any tool on any node.** The executor's credential may call everything,
as the console's already does; per-module grants on the calling side are not kept.
- **The mesh's own verbs stay with the controller** ([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)),
and a mesh-scoped seat's verbs run on the node that holds it
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)).
No hub is added; the controller's node is already one.
**The console is the executor, renamed.** It already runs on every node with a credential that
may call everything, and it already serves the mesh's tools to whoever is on the machine over
MCP on loopback ([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
It moves out of its container into the host's process tree, gains the serving half, and takes a
name that says what it is — *the node's tool runtime* or simply *node tools*; "console" names
the operator's half only.
## What it supersedes, and what it keeps
| record | effect |
|---|---|
| [ADR 0047](../../02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md), [ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md) | superseded *for tools*: one process per node runs every module's tool code, under one account. A module's long-running service — a daemon, a container — is untouched; the executor runs tools, not services. The record must say why one account for every module's tools is acceptable: every tool may be called from every node anyway, and root is taken by the tool, not granted to the process |
| [ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md) | kept in substance — a module, assigned per node, loopback MCP, the machine's login is the authority — changed in form: host-side, not a container; serves as well as calls; renamed |
| [to-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §6, [to-be 34](../../03-DESIGN/01-to-be/34-the-console.md) | amended the same way |
| [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §3, *a container may ask for a capability* | moot for that holder: the verbs run on the host side and escalate as they need |
| the container-runtime seat, proposed in an open change: *the holder runs as a supervised process and serves the verbs locally to the host and on the bus* | consistent — a supervised process serving verbs is what the executor is; the open question is whether that holder keeps its own process or serves through the executor like everyone else |
| the tool-runtime base image | no longer the way tools reach a node; may remain the way a module's *service* is built |
## What stays open
- **The executor's language.** The host is a static Go binary and loads no plugins, so the
executor is a sibling process, and its language decides the language of every tool bundle.
One decision, taken once.
- **How a bundle reaches the node.** An artifact of the module's build, delivered as the host
delivers everything else; whether it is a file resource in the declaration or a thing the
executor fetches by digest.
- **Reload.** A push that adds or upgrades a module's bundle reaches a running executor as a
reload, not a restart, or every tool on the node blinks on every push.
- **The host's own questions.** [Issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)
wants a machine to say more about itself. With an executor on every node, "what is this
machine made of" is a seat verb like any other, served there.
@@ -0,0 +1,65 @@
# 04 — The seats of the environment
*Candidates, not decisions. To-be 33 says which verbs a seat serves is a decision per seat,
taken slowly, because a seat's tools bind every future holder. This document lists the roles the
operator's machine has once, who could hold each, what gates it, and a first verb or two — so
each record has a starting point.*
## The rule for what is a seat here
A role the machine fills **at most once** is a node-scoped seat, declared by the module family
that fills it ([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)). A thing
several of which coexist without contention — editors, browsers, media players — is not a seat;
each is a module with its own tools, and nothing is singular about it. A seat is held by one
assignment per node; other modules of the same family may be installed beside it without
holding it ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) §1, read with the sharper
distinction: *installed* is not *holding*).
## Candidate seats
| seat | holders | gated by | first verbs |
|---|---|---|---|
| **login shell** | zsh, fish, bash | nothing: universal | `execute(command)`; `show-config`; the holding itself sets the account's login shell through the host's `user` shape |
| **service manager** | systemd | the `service-manager` capability the profile reports | units: list, status, start, stop, restart, enable, journal; **user scope** on each |
| **boot** | grub, systemd-boot | a machine that boots itself (not a container host) | `rebuild-images`; `entries` |
| **package manager** | pacman, apt | the `package-manager` capability | search, installed, upgrade, orphans; today a capability the host uses, not a seat anyone holds |
| **display server** | xorg, wayland compositors that are their own server | the `graphical-session` capability | `displays`; `layout` |
| **display session** | i3, sway | the display server seat held on the node; i3 needs x11, sway needs wayland | `reload`; `workspaces`; `windows`; `move` |
| **terminal emulator** | xterm, alacritty, foot | display session | `open`; `font` |
| **launcher** | rofi, dmenu | display session | `show`; `theme` |
| **notifier** | dunst, mako | display session | `send`; `history`; `rule` |
| **compositor** | picom | display server (x11 only) | `restart`; `effects` |
| **lock screen** | i3lock, swaylock | display session | `lock` |
| **bar** | i3status-rust, waybar | display session | `reload`; `blocks` |
| **login manager** | lemurs, greetd | graphical session | `sessions`; `default-session` |
| **audio** | pipewire, pulseaudio | the machine reports a sound device | `sinks`, `sources`, `default`, `volume`, `mute` |
| **clipboard** | greenclip, cliphist | display session | `history`; `clear` |
Not seats, modules with their own tools: the editor, the browser, the mail client, the file
manager, the media player, the chat client, the agent at the terminal, the downloads folder, the
scripts folder, the sync client, the power and thermal daemons that are specific to one machine's
hardware.
## What the table implies
**Capabilities come first.** `graphical-session`, `service-manager` and `package-manager` are
reported today. *A display server is held* is not a capability but a seat being held, and a
module that needs it declares a dependency on the seat, not on a capability: *i3 needs the
display server seat held by xorg*. Whether a held seat can gate another's assignment is a
question for the controller's resolver, and the first environment module after the shell will
ask it.
**The service manager comes early.** Four of the predecessor's modules ship user units, and the
executor itself is a unit. User scope on the host's `service` shape is a host change whichever
module holds the seat; the seat's holder answers the questions about units, it does not apply
them — the host does, as it does for every declared resource.
**The shell comes first.** Universal, no capability, one verb that is immediately useful on
every node, and the `user` shape already makes the login shell declared state. It is the module
that proves the pattern: a package, files under the home owned by the account, a seat claim,
tools served by the executor, settings for the few things that vary per node, and a kept region
for the operator's own lines.
**The login manager is the first system-scope one**, because it needs nothing new: a package,
two files under `/etc`, a service — the same shape the ssh daemon module has today — and the
session script it owns is the file that was hand-fixed the day this effort opened.
@@ -0,0 +1,55 @@
---
status: active
initiated: 2026-10-02
touches: [lab, the lab module, the catalogue, assignments, settings, the controller's store]
became: []
---
# 019 — A warm twin of the running mesh
## What is being investigated
Whether the lab can keep a **warm twin of the mesh as it actually runs**: the same machines, carrying
the same catalogue, the same assignments and the same settings as the live mesh, raised once and kept
ready, so that a change can be tested against the mesh as it is rather than against a scenario
written to resemble it. A run against the twin would go through the lab module like any other run:
a branch per repository, the twin restored from its snapshot, the change applied, the beds run.
## Why
The lab's beds raise meshes from declarations written for the bed. They prove the mechanism. They
do not prove that a change works on the mesh that runs, with its accumulated assignments, its
operator settings, its adopted machines and its modules in their real combinations. The gap showed
on 2026-10-02:
- a change to how a module's settings reach its files was correct in every bed, and would have put a
setting into the container runtime's configuration on every machine running that module. Only the
composed plan for a real machine showed it;
- a firewall change composed cleanly and still left one machine's wired port unfiltered, because
of a link that machine had and no bed did;
- a recovery step was needed on every machine at once, after a change that every bed had passed.
The lab already has a warm mode, a snapshot of a raised scenario restored between attempts. What it
does not have is a scenario that **is** the running mesh, kept current with it.
## What it touches
- **What a twin is made of.** The catalogue and the assignments are records; settings are records;
secrets are sealed to machines and cannot be copied. Which of these can be carried to the lab as
they are, which must be substituted, and how a twin says what it substituted.
- **Data.** A twin with the real catalogue and no real data proves composition and delivery, not a
migration. Whether a twin carries data, a sample of it, or none.
- **Keeping it current.** A twin raised once goes stale with the first merge. Whether it is
re-derived from the live records on each run, refreshed on a schedule, or rebuilt only when asked.
- **Machines.** The live mesh has machines of different kinds: a server on the internet, machines
behind a home router, a laptop that sleeps. Which of their properties a twin must reproduce for a
test to mean anything (reachability, the private network, the found firewall).
- **Cost.** The lab machine's memory and disk, and how long a twin takes to raise from cold.
- **The lab module's tools.** A run against the twin rather than a named bed: one more tool, or an
argument to the run tool.
## Starting point
The lab module (ADR 0172) runs beds through the mesh, and the lab's warm mode already snapshots and
restores a raised scenario. The beds that raise a machine shaped like one live machine from the
catalogue are the nearest existing thing, and the first to compare against.
+2
View File
@@ -78,6 +78,8 @@ capability. The host hardcodes no firewall, supervisor, package manager or runti
generic apply primitives and platform detection, so it runs where none of those exist — an Android
phone has no ufw, systemd, pacman or Docker.
> **The mechanism changed — 2026-10-02, by [ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md).** The shell example above — *bash, zsh and fish all join `shell`; one may be default* — is read as *installed is not holding*: the three may all be installed, and the `login-shell` seat is node-scoped and held by exactly one. The decision — what a module is, and the three relationships — stands; [ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) applies it to the operator's whole machine.
## Consequences
- **Supersedes the earlier "grouped by domain" decision** (folded in consolidation; see the
@@ -9,6 +9,8 @@ extends: 0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
# 47. A module runs its code as its own process, with its own account
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** A module's *tools* are no longer served by the module's own process under its own account: one tool runtime per node, on the host side, serves every assigned module's bundle. A tool is still served on its own subject and only the module that serves it answers; what moved is the process and the account.
## Context
A module is one self-contained thing ([ADR 0040](0040-what-a-module-is.md)), and it gets a broker
@@ -204,3 +204,14 @@ only, the refresh token only, the manager node only.**
record extends, amended to describe the adapter generalisation.
- The read-only vendor-agnostic analysis, 2026-09-05 (code workspace) — the inventory and the decisions
taken on the open questions this record encodes.
> **The mechanism changed — 2026-10-02, by [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md).**
> What stands: `model-access` is one vendor-blind provision for the consumers that do not care which
> vendor answers; a vendor's lifecycle is an adapter's; the carve-out that one node holds a refreshable
> grant's refresh token readably. What moved: the Anthropic adapter is no longer a part of the
> controller's licences context but a module, `claude-licence-manager`, holding the seat
> `anthropic-licence-manager`, with the grants in its own store encrypted with a key the vault made for
> it; and the agent at a terminal is not a consumer of `model-access` — it is coupled to an Anthropic
> grant and uses the seat. The consequence above that the three binding columns *become three ordinary
> consumers of `model-access`* therefore no longer describes the agent's bindings; they are the
> manager's. The static-key adapters and the vendor-blind records stay where this record put them.
@@ -283,3 +283,13 @@ modules in the catalogue require it — so a shared secret is a requirement answ
which is what this record asks for. Private keys are still made where they are used and never
travel, which is the other half and was never in question.
> **The mechanism changed — 2026-10-02, by [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md).**
> What stands: every shared secret the mesh makes is the vault's, a private key is made where it is
> used, and a long-lived value a backend issues enters the vault's custody — here as the key the vault
> makes for the licence manager, which encrypts the vendor's grants at rest with it. What this record
> did not foresee: a credential that lives hours, issued by a vendor to the one module that holds its
> grant, and handed by that module to the agent on each node sealed to that node's module key, on
> request/reply over the bus, never through the vault and never as a file the host writes. ADR 0183
> states that as a bounded exception — one vendor, tokens that live hours, one recipient per message —
> and a second such channel is a decision of its own.
@@ -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
> **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
The repository answers "what runs a module's own code" two ways and reconciles them nowhere
@@ -111,6 +111,8 @@ operator owns; narrowing what it may call is a setting on its assignment, which
[ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) already provides for
and nothing here builds.
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** The surface stays a module assigned per node, on loopback, with the machine's login as the authority. It is no longer a container: it is the node tools runtime's serving mode, host-side, and that runtime also serves every assigned module's tools. The module is renamed `node-tools`.
## Consequences
- **The way a person drives the mesh is inside the mesh.** It is declared, delivered, replaced and
@@ -94,6 +94,13 @@ itself restarts, and the console says so rather than hiding the modules' tools w
**A grant of `*` reaches a role's tools; `seat:<seat>.<verb>` grants one.** The console's `*` needed no
change to reach the mesh's verbs, which is what a grant meaning *every tool* should mean.
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).**
> The seat gains a generic verb beside the named ones: `command`, which takes one command line as the
> controller's binary takes it and answers what it printed. The named verbs stand and keep their
> schemas; `command` is the whole binary, added because the operator decided any node may call any
> tool and a verb per command was the only thing keeping `node account`, `node show` and the rest
> behind a shell on the control node. Additive within the version, as §"additive" above allows.
## Consequences
- **The console answers the mesh's own questions.** Issue 147's first paragraph closes: what a node
@@ -0,0 +1,136 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
---
# 168. A converged machine is filtered by the mesh alone, and the host says what else refuses
## Context
[ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) says what converging does to
the firewall a machine was found with: the mesh's derived filter is loaded in place of the
refusal-only guard, and the found firewall is retired — disabled, never flushed. Four issues from
the first two convergences are four ways that sentence was not the machine:
- the flip reported the found firewall retired and it was active two minutes later; fifty minutes
on, a reconcile found it disabled by hand and recorded that the mesh had done it
([143](../04-ISSUES/143-converging-does-not-retire-the-firewall-it-found/00-report.md));
- "the firewall found" named one front end, and what filtered the forwarded path on that machine
was a chain a predecessor had installed in the container runtime's user hook — invisible to the
mesh, refusing two ports the mesh declared open, and when it was removed, carrying an allowance
every module reaching another by the machine's own name had been relying on
([144](../04-ISSUES/144-the-predecessors-rules-outlive-the-firewall-it-was-found-as/00-report.md),
[145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md));
- the forward chain listed address ranges that followed neither the modules nor the machine
([141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)), answered by
[ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md) before this record;
- the networking module wrote two machine-wide files whole, so taking it restarted every
container ([084](../04-ISSUES/084-taking-networking-on-an-adopted-node-restarts-every-container/00-report.md)),
answered by [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md) and the hosts
file's marked region ([issue 128](../04-ISSUES/128-the-hosts-file-is-written-whole/00-report.md)).
Read on the four machines of this mesh on 2026-10-02, after every one had converged: on both
machines that had a front end it is inactive, and the host's record says the mesh retired it on
both — true of one, false of the other. On the home server the predecessor's chain is still in
force on the forwarded path, in the legacy packet filter the mesh's reader of rules does not
consult once a machine is converged, so that machine is filtered by two things and the mesh says
one. The host's reader already knows how to tell a table that refuses traffic from the runtime's
own plumbing and from a ban list; it is asked once, at adoption, and only to refuse a machine
whose firewall nobody speaks. Nothing asks it afterwards, and nothing reports what it saw.
The group's exit is one sentence: *a converged machine has exactly one thing filtering it, and
the mesh says truthfully which.* The first half the mesh can enforce only for what it owns; the
second half it can always do, and it is the half that was missing.
## Decision
**1. Convergence is a state the host keeps, not a step it takes once.** Every apply of a converged
declaration reads whether the found firewall is in force. Active — enabled again by a package, a
boot, a hand — it is retired again and said. The record distinguishes *the mesh disabled it* from
*it was found inactive*, and a reconcile that finds it inactive never records that the mesh did
it. When the step is skipped because the apply had failures, the report says the found firewall
was left in force and why; a step that does nothing is never silent.
**2. The host reports what filters the machine, with every apply, adopted or converged.** Every
table of the packet filter, and every chain of the legacy filter, that refuses traffic — a drop or
a reject, or a base chain whose policy drops — with an owner: the *mesh's*, the *found firewall's*,
the *container runtime's own*, a *ban* (a refusal that names the sources it refuses, in a chain
that accepts nothing), or *other*. The runtime's own is its plumbing — its chains, the forward
policy it sets when it turns forwarding on, its guard against reaching a container's address from
off its bridge. The user chain the runtime leaves for an administrator is not the runtime's:
anything refusing in it is *other*, which is where both predecessors' chains lived. Each entry
says in one line what it refuses. The mesh removes none of it: a rule it did not write is the
operator's to remove, now that they can see it.
**3. The mesh says which.** `node show` lists the filters with their owners. `status` names every
converged machine that something other than the mesh's table, the runtime's plumbing and a ban
list filters, the way it names strays and untaken modules, and such a machine is not "all well".
The converge preview lists the filters found and the fate of each: the found firewall retired, the
runtime's and the bans left, *other* left and named — so a person knows before the flip that the
machine will not be filtered by the mesh alone until they remove it, and what they would be
removing. *A converged machine is filtered by the mesh alone* when its list holds nothing but the
mesh's, the runtime's own and bans.
**4. Adoption's threshold does not move.** A machine whose front end nobody speaks is still refused
adoption; a refusing rule in the runtime's user chain still does not refuse it — on both machines
of this mesh it would have, and the migration would not have happened. It is reported instead,
from the first report on.
**5. Two of the group's issues are settled by records already accepted.** The forward chain follows
the machine's outward links and says nothing about networks ([ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md)),
which answers 141 whole. The runtime's file is written into and reloaded, and the hosts file's
region is the mesh's alone ([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md),
[issue 128](../04-ISSUES/128-the-hosts-file-is-written-whole/00-report.md)), which answers 084. One
machine-wide file the mesh still writes whole is its own filter, at the path the distribution's
packet filter reads; an operator's own rules at that path would be contested, and are held as
found until the filter module is taken ([ADR 0163](0163-taking-a-module-over-is-a-comparison.md)).
That is a difference a take shows, not a fault, and is decided when it bites.
## Consequences
- The host's report grows by the filters it found and, for a converged machine, the state of its
found firewall and who retired it; the controller keeps both on the node's record.
- `retireFirewall` runs on every converged apply and can disable the found firewall more than
once; the record's *disabled by the mesh* means exactly that.
- The reader of rules gains an owner per table and chain; what it refuses adoption for does not
change. A ban stays what it was: not a firewall.
- Issues 143 and 144 close on rules 1 to 3 once a machine's record names the predecessor's chain;
141 closes on ADR 0140 and 084 on ADR 0102, both by reading.
- Removing what is reported is the operator's act, by hand, with the preview's words in front of
them. The mesh never flushes and never deletes a rule it did not mark.
## How this is checked
| Rule | Checked by |
|---|---|
| Every refusing table and chain is classified, the user chain's refusals as *other* | host tests over rulesets captured from three machines of this mesh: a predecessor's chain in the legacy filter, a ban list and empty front-end chains beside the runtime's, a virtualisation host and an endpoint agent that refuse nothing |
| The found firewall active again on a converged machine is retired again and said; found inactive is recorded as found, not done; a skipped step is said | host tests over a fake front end |
| The report carries the filters and the found firewall's state for a converged machine | a host test reading the report |
| `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)
- [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md), [Design 05 — The node host](../03-DESIGN/01-to-be/05-the-node-host.md)
- Issues 084, 141, 143, 144, 145
@@ -0,0 +1,89 @@
---
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 |
@@ -0,0 +1,103 @@
---
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)
@@ -0,0 +1,59 @@
---
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 |
@@ -0,0 +1,108 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0040-what-a-module-is.md
---
# 173. The operator's machine is the mesh's, and a module is whatever it declares
## Context
[ADR 0040](0040-what-a-module-is.md) says a module is *one self-contained piece of software the
mesh installs and manages*, and every example it gives is a service: a database, an analytics
server, a forge. The catalogue followed the examples. Of the predecessor's 34 modules on one
workstation, 28 are the operator's environment — a login manager, a window manager with 88 files
and four flavors, a shell, a terminal, a launcher, an audio setup, scripts — and the migration
scoped all 28 out as *the workstation's own environment*, to be managed by nobody
([research 018](../01-RESEARCH/018-the-operators-machine-as-modules/02-what-exists-and-what-is-missing.md)).
Since the predecessor retired, nobody is exactly who manages them: a fix is a hand edit that
nothing records and nothing regenerates.
[To-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) reached under the home for
one directory and drew a boundary inside it. The operator's statement is wider: *the mesh manages
my entire machine, all four of them, as far as it makes sense* — system folders and the home
alike, the servers and the workstations from the same catalogue. And the operator refused a
distinction this effort first drew between modules that ship code and modules that ship only
declarations: *a module can have some tools, a seat implementation, some containers, a unit, a
binary, some config files — one of these, or all, or two.*
## Considered Options
1. **Keep 0040's reading and manage the environment outside the catalogue** — dotfiles in a
repository, a script that places them. Rejected: that is the predecessor's first two days, the
origin of every inherited shape [as-is 10](../03-DESIGN/00-as-is/10-module-catalogue.md)
documents, and it puts the one thing a person looks at outside the one mechanism that is
checked.
2. **Add a second kind of module for configuration** — a "config module" with files and no
process. Rejected by the operator: a kind is a distinction the manifest already makes by what
it declares, and a second kind is a second set of rules to keep in step.
3. **One definition: a module is one managed thing, described by what it declares.** Chosen.
## Decision
**1. Everything configurable on a node is declared by a module.** Services, and equally the login
manager, the display server, the window manager, the shell, the terminal, the launcher, the
notifier, the audio setup, the boot images, the package manager's configuration, the agent at the
terminal, and a folder a person works in. The test is *can it be configured on a machine*; if it
can, some module owns it. What no module declares is found and left alone, as adoption already
says of a machine ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)).
**2. A module is whatever it declares, and there are no kinds of module.** A package, files, a
container, a unit, a binary, a seat claim, tools — any one, or all. 0040's *one self-contained piece
of software* stands; its examples were services, and that was the whole of the bias. A downloads
folder with a process that tidies it, backs it up and answers questions about it is a piece of
software by 0040's own test, and so is a shell that is a package, three files and a seat.
**3. The home has no boundary of its own.** A file under the operator's home is placed and owned
the way [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §2 built it: by a
module, resolved against the account's home, owned by the account. Which files are the mesh's is
decided by what modules declare, not by a line drawn through a directory. A person's documents,
projects and history are data under [ADR 0051](0051-shared-data-is-the-operators.md) and no module
declares them.
**4. One module ships one default configuration.** No flavors. What differed between the
predecessor's four flavors of one desktop module is what [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
is for.
**5. Servers and workstations take the same catalogue.** A module declares what it needs; a
machine reports what it has; assignment refuses by name
([ADR 0161](0161-what-deserves-a-seat.md) §3). The shell, the prompt, git and the agent are universal.
A display server needs a graphical session; a window manager needs the display server held. Nothing
in a manifest says *workstation*.
## Consequences
- The catalogue grows by a family of modules that run no service. Each is still built,
registered, assigned, pushed and reported like every other, and `status` says whether a
machine has applied them.
- The account fact becomes load-bearing for every node a person uses. Today it is empty on all
four node records of this mesh; stating it is the first step of the build.
- A module that *installs* a thing is distinct from a module that *holds its role*: zsh, fish and
bash may all be installed, and one holds the login shell
([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)).
- The host's `package` shape drives the distribution's package manager only. A module whose
package is outside the distribution's repositories — the login manager in use is one — needs
either an official package or a shape the host does not have. Recorded as a gap, not decided.
- The predecessor's hooks go. What they did becomes declared state the host applies, or a verb a
seat serves ([ADR 0177](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)).
## How it is checked
| Rule | Checked by |
|---|---|
| A manifest with no container, no unit and no binary registers and resolves like any other | the catalogue's registration tests, with a package-and-files manifest |
| A file resource under the home resolves against the account and is owned by it | the controller's composition tests (to-be 29 §2, built) |
| A home-scoped module is refused on a node with no account, naming the fact | the same tests |
| A module needing a capability the machine lacks is refused by name | the resolver's tests (ADR 0161 §3) |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/00-overview.md), documents
01 and 02 — the behaviour wanted and the inventory measured.
- [ADR 0040](0040-what-a-module-is.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md),
[ADR 0051](0051-shared-data-is-the-operators.md), [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)
- [To-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the account and the home
as a placement root, built; the records for them are proposed in an open change.
@@ -0,0 +1,92 @@
---
topic: building it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
---
# 174. A node varies a module through settings and kept regions, never through an edit
## Context
[ADR 0011](0011-managed-files-are-generated-never-edited.md) says a managed file is derived and an
edit to it is overwritten without warning. The predecessor said the same and then undid it twice:
a `merge` strategy that adopted disk drift back into its database, so a local edit became the
record; and a theming layer of about 90 environment variables substituted into templates at sync
time, with tools to list and set them, so that *nearly every value was a variable* — a second
configuration language laid over the first.
The operator wants both the variation and the rule. One window-manager module with one default
configuration, and each node tweaking it; and the file carrying the wanted value rather than a
variable the file reads. Two mechanisms already exist for exactly this: a **setting**, declared by
the module and set per mesh or per node, rendered at composition
(`${setting:…}` is live in the resolver's manifest); and a **kept region**, a block in a file the
mesh writes *into* where the operator's own lines survive every push
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), used by the ssh-client module
for the operator's own `Host` blocks).
What stands in the way is [issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md):
a setting today reaches every mergeable file and every contribution of its module. Ninety theme
knobs on that mechanism would reach ninety files. The record that fixes it — a setting declared
with its type, meaning, default and the file it lands in — is proposed in an open change alongside
the container-runtime records.
## Considered Options
1. **Carry the predecessor's merge strategy.** A local edit is adopted into the node's layer.
Rejected: two writers and no arbiter, which is the option 0011 removed, and the reason a
`/model` choice was silently reverted on every node for weeks before anyone found the cause.
2. **Carry the environment-variable theming.** Rejected by the operator: the value belongs in
the file; a variable the file reads is a second place for the same fact.
3. **A per-node file override** — a whole file replaced for one node. Rejected: it is a flavor
under another name, and a module update then misses that node entirely.
4. **Settings rendered into the file, and kept regions, and nothing else.** Chosen.
## Decision
**A node varies a module in exactly two ways.**
- **A setting.** Declared by the module with a default, set for the mesh or for one node, rendered
into the file at composition. The value is in the file. Asked, the mesh lists every setting
with its effective value and where it came from.
- **A kept region.** A marked block in a file the mesh writes into, in which the operator's own
lines are kept across every push and given back when the module goes (ADR 0102).
**An edit outside a kept region is overwritten, as ADR 0011 says, and never adopted.** Nothing
reads a managed file back into the record.
**The predecessor's theme knobs become settings** of the modules whose files they render — the
window manager's colours are the window manager's settings, the bar's are the bar's — each
landing in the file that reads it and no other.
**Issue 168 is fixed before any environment module declares a setting.** A setting must name the
file it lands in; until that ships, the environment modules carry their defaults in their files
and no settings.
## Consequences
- No flavors, no per-node file copies, no environment layer. A module's definition is one set of
files; a node's difference is data in its layer, visible by asking.
- The settings record proposed alongside the container-runtime records is on the critical path
of every module with a knob, and this record depends on it shipping as proposed.
- A kept region is the only place a person edits a managed file, and the file says where it is.
The operator's own prompt customisations, aliases and window rules live there.
- What got harder: a change that is neither a setting the module declared nor the operator's own
lines has no home, and is refused by the mechanism rather than silently kept. That is the point.
## How it is checked
| Rule | Checked by |
|---|---|
| A setting reaches only the file its declaration names | the controller's settings tests, once the proposed record ships; issue 168 closes on it |
| A kept region survives a push with its content and is given back on undeclare | the host's write-into tests (ADR 0102), with a region declared by an environment module |
| An edit outside a region does not survive a push | the same tests, asserting the file equals the composed content outside the region |
| Every effective value names its source | `mesh-controller.settings` and the module's own `show-config` tool |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/01-the-intended-behaviour.md) §"One default, varied by settings, never by edits"
- [ADR 0011](0011-managed-files-are-generated-never-edited.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md),
[issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)
@@ -0,0 +1,122 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
---
# 175. One tool runtime per node serves every module's tools, on the host side
## Context
A module's tools are code the module wrote, one function behind each verb, served on the subjects
the controller issues in the module's membership
([ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)).
What *runs* that code is [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md):
a supervised process per module under the module's own account, and in the catalogue as built,
that process is a container per module per node, built on the tool runtime's base image.
Measured on the live mesh ([research 018](../01-RESEARCH/018-the-operators-machine-as-modules/03-one-tool-executor-per-node.md)):
67 module tools, each served from its module's container; the packet-filter seat's three verbs
served by a container with `NET_ADMIN` on every one of four machines, for a module that is
otherwise a package, three files and a service; and the console, a container per node, calling
everything and serving nothing. The operator's environment adds a dozen modules of the
packet-filter shape, and the operator's judgement is plain: *I would never run MCP tools inside
a container; that is a very bad design.* And: *I don't care about permissions or account per
module, that just complicates things for no good reason. Just a node-level tool executor. If a
command needs root, that's the module's concern.*
The tool runtime itself was written for this. Its own description: *the per-node process that
makes a module's tools actually serve — imports the assigned modules' compiled tool entrypoints,
each of which registers its tools as it loads; on a node the host resolves the list and starts it
like any other supervised workload.* What the catalogue did instead was build one image per module
around it.
## Considered Options
1. **Keep a process per module.** Rejected: one container per module per node for software that
is not a container, and the account-per-module invariant it exists to protect is one the
operator declines to pay for.
2. **The host executes tools itself.** Rejected: the host is a static Go binary that loads no
plugins; a module's tools are TypeScript on the SDK, and building a second SDK in Go for the
host's sake is the cost ADR 0039 refuses.
3. **One tool runtime per node, a sibling of the host, loading every assigned module's bundle.**
Chosen. It is what the runtime was written to be.
## Decision
**1. One tool runtime per node, supervised by the host, on the host side — never a container.**
The host starts it the way the launcher starts the host
([ADR 0005](0005-the-node-host.md)): a process on the machine, restarted when it dies. It holds one
bus credential, the node's. It is module-agnostic: it knows bundles and subjects, nothing of what
any module does.
**2. It serves every assigned module's tools and every held seat's verbs** on the subjects the
memberships issue. ADR 0159 and ADR 0160 are unchanged in what they say about subjects, grants
and memberships; what changes is that one process on the node subscribes to all of them instead of
one process per module. A module that runs a long-lived service of its own — a daemon, a
container — keeps it; this record is about tools.
**3. A module brings its tools as a bundle**, the artifact kind the catalogue already has for
interpreted code, built by the pipeline and delivered to the node by the host as it delivers any
artifact. Never an image. The runtime loads each bundle as the membership names it, and a push
that adds or replaces a bundle reaches a running runtime as a reload.
**4. Root is the module's concern.** A tool that must change the packet filter or rebuild boot
images escalates itself. The runtime does not run as root for everyone's sake; the caller does not
know and need not.
**5. Any node may call any tool on any node.** The runtime's credential may call everything, as
the console's already may. A per-module calling grant is not kept.
**6. The console is this runtime's serving mode, renamed.** [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md)
stands in substance — a module assigned per node, MCP on the machine's loopback, the machine's
login is the authority — and changes in form: host-side, serving as well as calling, and named for
what it is: **node tools**. The mesh's own verbs stay with the controller
([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)); a mesh-scoped seat's verbs
run on the node that holds it ([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)).
**Where ADR 0047 and ADR 0150 say a module's tools are served by the module's own process under
the module's own account, read this record.** Everything else they decided stands: a tool is served
on its own subject, only the module that serves it answers, a module's long-lived processes are the
machine's to supervise. The invariant 0150 kept — one account per module — no longer holds for
tools, and the reason is stated above: every tool is callable from everywhere by decision 5, so the
account no longer scopes anything a caller cannot already reach.
## Consequences
- The packet-filter module's container goes; its verbs run on the host side and escalate as they
need. [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md) §3's container capability is moot
for it.
- The tool runtime's base image stays the way a module's *service* may be built; it is no longer
the way tools reach a node.
- The node tools runtime needs an interpreter on the machine. The module that is the runtime
declares it as a package.
- The container-runtime seat proposed in an open change says its holder *runs as a supervised
process and serves the verbs locally to the host and on the bus*. A supervised process serving
verbs is what this runtime is; whether that holder keeps a process of its own or serves through
the runtime is for that record's build to say.
- What got harder: one process carries every module's tool code on a node, so one module's
faulty bundle can take down the node's tools. The runtime loads each bundle guarded and names
the one that failed; the others serve.
## How it is checked
| Rule | Checked by |
|---|---|
| The runtime loads every bundle its memberships name and serves each tool on its subject | the runtime's tests against a real bus: two bundles, three tools, each answers |
| A bundle that fails to load is named and the others serve | the same tests, with one bundle that throws on load |
| The host supervises the runtime and restarts it | the host's tests over the launcher's shape |
| A push that replaces a bundle reloads it without a restart | the runtime's tests: a bundle replaced on disk, the membership re-read, the new tool answers |
| No module in the catalogue declares a container whose only purpose is tools | a catalogue check: a manifest with `tools` and an image artifact built on the tool runtime's base is refused once the runtime is live |
| Live | `login-shell.execute@<node>` answers on every node from the node tools runtime; `docker ps` shows no per-module tool container |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/03-one-tool-executor-per-node.md)
- [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md), [ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md),
[ADR 0152](0152-the-operators-surface-is-a-module-the-console.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 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)
- [To-be 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md), [to-be 34](../03-DESIGN/01-to-be/34-the-console.md)
@@ -0,0 +1,81 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
---
# 176. The login shell is a node seat held by one shell module, and `execute` is its contract
## Context
[ADR 0040](0040-what-a-module-is.md) names the shell as its example of a *shared* seat: bash, zsh
and fish all join `shell`, and one may be default. The operator's reading is sharper, and it
matches [ADR 0126](0126-a-module-declares-its-own-seats.md) better: *installing* a shell is
installing software, and several may be installed; *holding* the seat is being the login shell,
which a node has exactly one of. A definition says which seats a module can hold; the assignment
says which it does.
A seat carries the tools its holder must serve ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)),
and [to-be 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) leaves which verbs each
seat serves as a decision per seat, taken slowly. This is the first seat of the operator's
environment, and the one every node has.
## Considered Options
1. **A shared `shell` seat with a default**, as 0040's example reads. Rejected: *default* is a
second concept beside *holder* for the same fact, and the `user` shape already makes the
login shell declared state ([to-be 05](../03-DESIGN/01-to-be/05-the-node-host.md)).
2. **No seat; each shell module sets the login shell for itself.** Rejected: two assigned shell
modules would fight over `chsh`, and nothing would say which won.
3. **An exclusive node-scoped seat, `login-shell`, declared by the shell modules, held by one
per node.** Chosen.
## Decision
**1. `login-shell` is a node-scoped seat declared by the shell modules.** zsh, fish and bash each
declare that they can hold it; a node's assignment says which does; the controller refuses a
second holder by name as for every seat. A shell module that is assigned without holding the seat
is installed and nothing more.
**2. Holding the seat sets the account's login shell.** The holder's declaration carries the
`user` shape with the shell it provides, so the login shell is declared state the host applies and
gives back when the holding moves — `chsh` stops being a hook.
**3. The seat's contract is `execute`.** One verb, one argument, the command, run on the node the
seat is scoped to as the operator account, answering with what it printed and how it exited.
Every holder serves it; a holder may serve its own tools beside it
([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md) §2) — show the rendered configuration, list
the plugins, set a prompt value.
**4. Any node may call it on any node.** The grant is the node tools runtime's
([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) §5):
*run `uptime` on every node* is five calls to one verb.
## Consequences
- The first environment module is a shell: a package, files under the home owned by the account,
a seat declaration and claim, a `user` shape, and one tool. It proves the whole pattern on every
node, servers included, before anything graphical is written.
- ADR 0040's shell example is read as *installed is not holding*; a dated note in that record says
so. Its decision is untouched.
- `execute` is a shell on every machine, addressed over the bus. That is the point, and it is
the widest verb the mesh serves; it exists because the operator decided every node may call
every tool, and this record does not narrow that.
## How it is checked
| Rule | Checked by |
|---|---|
| Two shell modules assigned to one node, one holding: one `user` shape in the declaration, naming the holder's shell | the controller's composition tests |
| A second claimant is refused by name | the catalogue's seat tests |
| `execute` runs as the account and answers output and exit status | the module's tool tests over a fake runner, and live on every node |
| The seat's verb appears with its scope and machine in the node tools listing | the runtime's tests (to-be 33 §4) |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md)
- [ADR 0040](0040-what-a-module-is.md), [ADR 0126](0126-a-module-declares-its-own-seats.md),
[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)
@@ -0,0 +1,80 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
---
# 177. A unit may be user-scoped, and the service manager is a node seat whose holder answers for the units
## Context
The host's `service` shape puts a system unit into a state. It has no user scope.
[To-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) states the gap: *a
workstation's per-user daemons have no form the mesh can send.* Four of the predecessor's
environment modules ship user units — the desktop's reload watcher and bar watchdog, the audio
module's masks, the power module's memory guard, the thermal daemon's profile switcher — and the
predecessor needed a hook to enable them because *shipping a unit file does not run it*; one unit
was deployed for months and ran on one machine only.
[ADR 0040](0040-what-a-module-is.md) says the host hardcodes no supervisor, and a swappable
machine mechanism is a module implementing a capability — which is what the nftables module is for
the packet filter ([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)). The service manager is
reported today as a capability, `service-manager`, and held by nobody. The operator's proposal: a
systemd module that holds the seat and serves the tools about units, system and user.
## Considered Options
1. **Keep user units as a module concern** — each module runs `systemctl --user` in a hook.
Rejected: that is the hook that silently never ran, and an action over the link is refused.
2. **The service-manager module applies units** on behalf of others, as a provision. Rejected by
the operator: provisioning is for resources a provider creates for a consumer; a unit is
declared state the host applies, as every resource is.
3. **The host's `service` shape gains a user scope; a systemd module holds the service-manager
seat and serves the verbs about units.** Chosen.
## Decision
**1. The `service` shape gains `scope`: `system` (the default) or `user`.** A user-scoped unit
is applied as the operator account through the account's own service manager: enabled, started,
stopped, reloaded on its triggers, exactly as a system unit is, and refused on a node with no
account, naming the fact. The host applies it; no module does.
**2. `node-service-manager` is a seat of the mesh's own, node-scoped**, seeded by the controller
under this record, as ADR 0121 requires of a `node-*` name. The `systemd` module claims it and is
assigned to every machine whose profile reports `service-manager`.
**3. The seat's verbs answer for every unit on the machine**, each taking an optional `scope`:
`units`, `status`, `start`, `stop`, `restart`, `enable`, `disable`, `journal`. The host applies what
is declared; the holder answers questions and operator acts about it, and says, for a mesh-held
unit, that the host will restore what its declaration says.
## Consequences
- The host's vocabulary grows by one field on one shape, asserted by its count test
([to-be 05](../03-DESIGN/01-to-be/05-the-node-host.md)); an older host refuses a declaration
carrying it, so the host rolls before the first module that uses it.
- The predecessor's four user-unit modules become declarable without a hook.
- The seat's holder is the first system seat held by a module that runs nothing of its own: its
verbs are served by the node tools runtime ([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
- What got harder: `journal` and `status` on a user unit need the account's manager reachable
from the runtime's process, which runs as the node's account; the holder's tool escalates or
switches user as it needs, which is ADR 0175 §4 applied.
## How it is checked
| Rule | Checked by |
|---|---|
| A `service` with `scope: user` is enabled and started under the account, and refused with no account | the host's tests with a fake service manager |
| The seat declares its verbs; a claim serving fewer is refused by name | the catalogue's seat tests |
| The verbs act on a named unit in the named scope and name the unit's holder when the mesh declares it | the module's tests over a fake runner |
| Live | the desktop's reload watcher declared `scope: user` on a workstation; `node-service-manager.status@<node>` reports it active |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md)
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md),
[ADR 0040](0040-what-a-module-is.md), [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)
- [To-be 05](../03-DESIGN/01-to-be/05-the-node-host.md), [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)
@@ -0,0 +1,132 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md
---
# 179. The intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail
## Context
Read on the control node on 2026-10-02, the day the machines were confirmed filtered by the mesh
alone ([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)): the intrusion
prevention watched one door. Its two jails read the ssh daemon's journal and its own log, banned
five failures in ten minutes for ten minutes, and in a day had seen twelve thousand failed logins
from three hundred addresses and banned none of the busiest, which paced themselves at one try every
ten minutes. The mail submission port took a hundred and sixty password guesses in the same day from
thirty-eight addresses with no jail reading it at all; the forge and the public proxy had no jail
either, and the proxy logged nothing a jail could read. Nobody could see the jails without a shell:
the module's three tools existed in code and were served by nothing, and the seat it holds declared
no verbs.
Three things were missing and they are three shapes the mesh already has. The packet filter's seat
serves verbs every holder owes ([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)); the
intrusion seat serves none. A module's `listens` compose into the machine's filter, and [to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
says a module's `jails` compose into the machine's intrusion prevention the same way — the controller
composes them, and no module declares one. And a jail reads a log; a container's output goes to a
file of the runtime's own under a path that changes when the container is recreated, which is why
no jail could read the mail front end, the forge or the proxy, however they logged.
## Decision
**1. The `node-intrusion-prevention` seat serves four verbs**, and a module that claims it serves
all four or is refused the claim, as with every seat:
- `status` — every jail with what it watches, how many addresses it is counting failures against and
holding now, and the totals since it started; one jail's detail when named. Read-only.
- `banned` — every address banned now, with the jail holding it, when it was banned and when the ban
ends. Read-only.
- `ban` — ban one address in one jail now, for that jail's ban time. An operator's act on the live
ban list, which the mesh composes the rules for and never writes itself.
- `unban` — let one address go, from one jail or from every jail.
A holder may serve its own tools beside these; the fail2ban module reads one jail's effective
settings as its own.
**2. A container may log to the journal.** `logging: journald` on a container has the host run it
with the journal as its log driver; the journal keeps the container's name on every line, and
`docker logs` keeps working. Where a container logs is part of its spec, so moving it recreates the
container, and the only place besides the runtime's own file is the journal: a machine's intrusion
prevention reads the journal already, for the ssh daemon, and a container that logs there is read
the same way, by the container's name, whatever the container is called by the runtime this time.
**3. A module with a door declares its jail, and the holder composes them.** What to-be 31 designed
is now the rule: a module whose service authenticates from outside — the mail front end, the forge,
the public proxy — declares in its manifest what a failed attempt looks like in its log and how to
ban on it, naming no node and no path; the module that holds the intrusion seat declares where the
composed jails and filters land, and the mesh writes them on every machine that runs both. A machine
not running the module has no such jail. The holder restarts its daemon on the composed file.
**4. The base is strict, and the mesh's own range is never banned.** Three failures in a day ban for
a day, on every jail unless the jail says otherwise; banned twice in two weeks, by any jail, is
banned for four. The attackers this mesh sees pace themselves under any ten-minute window; a day's
window counts them. A person who mistypes three times from one address is out for a day from that
address, and never from a machine of the mesh, whose range stays in the never-banned list the module
has carried since [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md). The operator
chose this knowing it.
**5. The proxy says a refused name in its log.** A request for a name this mesh does not serve, from
outside, is what a scanner does; the proxy already logged a certificate refused for such a name, and
now logs the plain request too, with the asking address last, as its own jail's filter expects it.
## Consequences
- The seat's row gains four verbs; a mesh that already runs widens its row at the next controller
start. The fail2ban module claims them and gains a runtime — a tool server whose image carries the
fail2ban client, with the daemon's socket shared in from the machine, and nothing else of the
machine. The daemon stays the machine's; what runs in the container is only the client.
- **That runtime is the shape the catalogue has today, and it is on its way out.**
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
accepted the same day as this record, replaces a tool container per module with one tool runtime
per node on the host side, taking each module's tools as a bundle. Nothing here depends on the
container: the verbs, the client that speaks to the daemon over its socket, and the jails are the
same code under either. This module converts with the packet filter's, whose runtime that record
names, and the socket it needs becomes the node runtime's to reach rather than a mount of its own.
- The host's container vocabulary grows by `logging`; an older host refuses a declaration that carries
it, so the host rolls before the modules. Three containers are recreated once, when their modules
are pushed with the field: the mail front end, the forge and the proxy — each a moment's outage.
- The fail2ban module declares where jails compose (`jailing`) and the directory the filters go in;
the mail, forge and proxy modules each declare one jail reading the journal by their container's
name. The composed jail file is the one resource the daemon restarts on when a module arrives or
leaves a machine.
- The two base jails and the composed ones take the day's window; the ssh jail's ten minutes are
gone. An address banned on the first day of this record stays banned for the day.
- The module's three old tools, served by nothing, are replaced by the seat's four verbs and one
own tool; `fail2ban_status` as a name is gone.
## How this is checked
| Rule | Checked by |
|---|---|
| The seat declares the four verbs; a claim that serves fewer is refused by name | the catalogue's seat tests |
| `status`, `banned`, `ban` and `unban` read and steer the daemon through its client, with the shapes fail2ban 1.1.0 printed live; a non-address and a non-name are refused before anything runs | the module's tests over a fake command runner |
| A container's `logging` reaches the runtime's arguments and its spec; a place other than the journal is refused | host tests |
| A module's jails compose into the holder's file and a filter per jail, and the file is written empty when none is declared | the controller's composition tests (to-be 31) |
| The proxy logs a refused name with the address last | the proxy's tests |
| A jail's pattern names `<HOST>` once per shape, since two is a duplicate capture group and costs the machine every ban | the catalogue's manifest tests |
| Live | done 2026-10-02: `status` and `banned` answered on both servers through the console; the proxy's jail counted seven refusals on the home server; a documentation address banned in the ssh jail came back with its end time and was released |
## Built and proven live, 2026-10-02
All five rules are in the mesh. The host carries `logging`; the controller's seat row carries the four
verbs and the proxy says a refused name in its log; the fail2ban module holds the seat from a runtime
with the daemon's socket shared in, composes the jails, and the mail front end, the forge and the
proxy each declare one. Through the console on the control node: `status` listed five jails with what
each watches, `banned` listed the nine the long jail holds, and a documentation address banned in the
ssh jail came back with its ban's end time and was released again. On the home server the proxy's jail
had counted seven refusals within minutes of starting.
**One fault, found by the machine and not by a test.** The proxy's pattern matched two shapes of
refusal in one expression and so named `<HOST>` twice. fail2ban expands that placeholder into a named
capture group; two of them is a duplicate group name, and the daemon refuses *its whole configuration*
and exits — both servers kept no bans at all for about ten minutes, every jail and not the one at
fault. The pattern is now one per shape. A manifest check refuses the mistake at merge time, naming
what it would cost, which is the only reason this record can claim the rule rather than the instance.
## References
- [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md), [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
- [Design 31 — A module declares its fail2ban jail](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.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)
@@ -0,0 +1,68 @@
---
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
---
# 180. The found front end is uninstalled once a machine is converged
> **Renumbered 2026-10-02.** Written and merged as 0175 while another record already held that number on main (one tool runtime per node, merged minutes earlier); `cycle.py` refused main. The branch that lands last renumbers: 0178 and 0179 are claimed by open changes, so this is 0180. Nothing cited it by number.
## Context
[ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) retires the firewall a machine
was found with by disabling it, never flushing it, and keeps its configuration on disk so that
returning the node to adopted can enable it again. [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)
made the host keep it retired and say so. Both machines of this mesh that had a front end have been
converged for days; neither is going back. What remained of the front end on each — its package,
its unit enabled for boot on one, its empty chains still wired into the kernel's hooks, a chain of
its container integration still dropping traffic on the IPv6 path until the day before this record
— was not a rollback path. It was software nobody runs, left where a reader finds it and asks
whether the machine has two firewalls.
The operator asked on 2026-10-02 that it be disabled and uninstalled. Disabled it already was. For
uninstalled, the host had no word: a package could be declared present and not absent.
## Decision
**1. A package may be declared absent.** `absent: true` on a package resource has the host remove
the package when it is installed and leave alone a machine that never had it, through the machine's
own package manager, dependencies untouched. A declaration that stops saying a package is absent
installs nothing: there is nothing to undo.
**2. The module that holds the packet filter seat declares the front end it replaced absent**, after
its own filter is loaded, so the mesh's table is in force before the front end's package goes. On a
converged machine the front end is therefore gone, not merely off; on an adopted machine nothing of
this runs, because the filter module is assigned by the flip and not before.
**3. Returning such a machine to adopted enables nothing.** A machine with no firewall needs no
openings ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)); the host records the
front end as *removed*, says so once, and asks nothing of a command that is not there. What
ADR 0100 kept on disk for a return is kept only as far as the package manager keeps a changed
configuration file; the rollback path it described is given up on purpose.
## Consequences
- The host's vocabulary grows by `absent` on a package; an older host refuses a declaration
carrying it, so the host rolls before the module.
- The nftables module's declaration gains one resource; on the two machines of this mesh that were
found with ufw, the next push removes it.
- `node show` reads *found firewall: ufw, removed* on those machines from then on.
- ADR 0100's sentence about a return to adopted restoring the found firewall holds only while the
front end is installed, which after this record it is not on a converged machine.
## How this is checked
| Rule | Checked by |
|---|---|
| An absent package is removed when present, left when not, and read back | host tests over a fake package manager |
| An uninstalled front end is recorded as removed and nothing is asked of it | a host test with ufw missing on a converged apply |
| Live | the two machines report ufw gone: `pacman -Q ufw` has no answer, `node show` says removed, `status` is well |
## References
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)
- [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md)
@@ -0,0 +1,118 @@
---
topic: what runs on it
status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: true
extends: 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
---
# 181. The operator account is a node fact, and a home is a placement root
*Reconstructed. The controller shipped this on 2026-09-27 and
[to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) recorded it as built without a
decision behind it. This record states what was decided, from the code and the design, and adds the
two rules the code left implicit — what an empty account means for a module, and that the account is
stated rather than discovered. Written 2026-10-02.*
## Context
[ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) took every host path out of a
module definition and gave a module's *system* data a place: a directory the mesh resolves under the
node's root, owned by the module. It said nothing about the other half of a filesystem — the files
that belong under a person's home and are owned by that person. The predecessor wrote several of
those: the ssh client configuration, the shell's configuration, an agent's instruction files. It knew
whose home it was writing into because each of its node records carried a login name. The mesh took
the machine facts over and dropped the human one.
The loss was found the ordinary way: `ssh <node>` logged into the home-server under the workstation's
own login name, because nothing in the mesh said the home-server's account was a different one
([to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md),
[issue 172](../04-ISSUES/172-the-ssh-client-block-matches-one-spelling-of-a-machine/00-report.md)).
What the controller does since 2026-09-27: a node record carries an operator account and, optionally,
its home; the account and its home are machine facts a definition may name in a resource's path, owner
and content; a roster file may say it lives under the home, and is then rendered per node, placed under
that node's account's home, owned by the account, and left out on a node with no account. On
2026-10-02 **all four nodes of the live mesh carry an empty account**: the fact exists and nobody has
stated it, so no home-scoped resource can land anywhere yet.
## Considered Options
1. **The definition names the login.** `owner: <name>` in the module. Rejected: it is the installation
written into a definition, which ADR 0112 forbids and
[ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md) checks for, and it is
wrong on the first machine whose login differs — which is exactly the machine that surfaced this.
2. **The host discovers the account.** The first non-system user, or whoever ran the enrolment.
Rejected: a guess. A shared machine has several people on it, a server may have none, and a host
deciding whose files these are is a decision the mesh then cannot see, state or correct.
3. **The account is a fact the operator states on the node record, and the home is derived from it
unless stated.** Chosen.
## Decision
**A node has an operator account: the login name of the person who works on it.** It is stated by the
operator on the node record, the way a node's address or mode is held there, and it is empty for a
machine nobody logs into. Empty is a real state, not a missing value. The mesh holds the fact because
everything below derives from it, and because it is precisely the fact that was lost when the
predecessor's records were not carried over.
**The account's home is derived unless stated.** The superuser's home for the superuser, the
distribution's conventional per-user home otherwise; a node whose account lives elsewhere states its
home. One place computes the default, so a fact and the record cannot disagree about it.
**A resource may be placed under the home, owned by the account.** This is ADR 0112's move one level
over: as a module's system directory is resolved under the node's root, a file under a person's home is
resolved against the account's home, and owned by the account rather than by root or a module's own
account. A definition names the account and its home as machine facts, never as a path; a roster fact
may say it is a home file and is then placed and owned the same way. The controller resolves both at
composition, and the host chowns what it creates.
**A node with no account cannot carry a home-scoped resource, and says so.** A roster fact that lives
under the home is left out of that node's declaration rather than written to nowhere. A resource naming
the account fact on such a node is refused at composition, naming the fact the machine does not have.
A module that writes a person's files is thereby unassignable to a machine with no person on it, which
is the right refusal.
**One account per node is what this record decides.** Several people on one machine is left open, with
the constraint that allowing it must not force the common case — one workstation, one person — to name
anything.
## Consequences
- **The operator states the account before any home-scoped module lands.** Today none is stated, so the
first assignment of such a module begins with four node records.
- The roster carries each node's account, so a composed ssh configuration logs in as the right person
on every machine — the gap that surfaced this, closed by the same fact.
- A family of modules becomes writable: everything the predecessor placed under a home — ssh client,
shell, the agent's instruction files — is now a module naming a fact rather than a path
([to-be 29 §2](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)).
- **What got harder:** a definition cannot say "my user's home" without the mesh knowing who the user
is, so a module of this family is refused on a freshly enrolled machine until a person is named on
it. That is a prompt, not an obstacle.
- **Not decided here:** several accounts per node; a service unit running as the account rather than
as root or a module; a one-off step run as the account. Each is a record of its own.
## How it is checked
| Rule | Checked by |
|---|---|
| A resource's path and owner resolve the account and its home | controller tests on machine-fact resolution: a file naming the account facts lands under the account's home, owned by the account |
| The home is derived unless stated | a controller test: the superuser's home for the superuser, the conventional home otherwise, the stated home when one is stored |
| A home roster fact is left out on a node with no account | a controller test on roster composition: the file is absent from that node's declaration and present on a node with an account |
| A resource naming the account on a node with no account is refused by name | a controller test on machine-fact resolution: the refusal names `account` and lists the facts the machine does have |
| No definition names a home path | ADR 0112's catalogue test on host paths, which a `/home` or `/root` literal fails |
## References
- [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the design this record
gives a foundation to, and its "what has shipped" section
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — the system-path placement
this mirrors; [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md) — why
a login name may not be in a definition
- [ADR 0120](0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster fact a home file
may be
- [ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) — what
the mesh may and may not do inside the home this record lets it reach
- mesh-controller `internal/inventory/nodes.go` (the account and its home on the node record),
`internal/catalogue/machine_into_files.go` and `roster.go` (resolution and the home fact)
@@ -0,0 +1,117 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md
---
# 182. 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
## Context
[ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) lets a module
place files under a person's home. A home is unlike any directory the mesh has written into so far:
it is shared with the person, and with every program the person runs. The agent's configuration
directory on the laptop makes the point. On 2026-10-02 it holds thirty entries. The predecessor placed
five of them (an instruction file, a conventions rule, a settings file it merged into, two skills); a
sibling module placed a sixth (the node's identity rule). The agent itself writes the other
twenty-four: its settings, its credentials, its history, the memory of every project it has worked in,
its plugins, its session logs. Several of those are what [to-be 15](../03-DESIGN/01-to-be/15-the-agent-session.md)
calls memory *written by the session itself and declared by nobody*: a mechanism that regenerated the
directory would erase a season of it, silently, while reporting success.
The predecessor's own module recorded the hazard in the other direction. Its settings file was first
shipped as *replace*, and every `/model` choice a person made inside a session was reverted to the
template's value on the next synchronisation — on every node, indefinitely, with no indication why. It
was changed to *merge*, and the comment explaining why is still in its manifest.
**And the generator is gone while its output stayed.** The predecessor was retired from the laptop on
2026-10-01. Its six files are still on both workstations, with their content telling every session to
use tools that no longer exist. Nothing owns them; nothing will ever rewrite or remove them.
[To-be 29 §3](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) drew the line for one directory,
`~/.ssh`: the mesh owns the directory and the files it places; it holds the person's private keys and
personal drop-ins as found. That was argued from the lockout `~/.ssh` can cause. The argument here is
the same shape with a different stake — the person's work rather than the person's way in — and it has
to hold for every directory the family of home-scoped modules will touch, so it is a rule, not a
section.
## Considered Options
1. **The module owns the directory whole**, regenerating it from the definition. Rejected: it destroys
the memory, history and local settings the agent writes for itself, which is the failure to-be 15
names and the predecessor's settings file demonstrated at small scale.
2. **The module owns only the files it names, and nothing about the directory.** Rejected: *owning one
file beside foreign ones is not owning anything* (to-be 29). The directory must exist, with the right
owner and mode, before the tool first runs on a fresh machine; and a credentials file in a
world-readable directory is a credentials file in the wrong directory.
3. **The module owns the directory and the files it places; a file the tool writes for itself is
written into, never over; everything else is held as found.** Chosen.
## Decision
**A home-scoped module owns the directory it declares: its existence, owner and mode.** The host creates
it if absent, owned by the account, and never removes it while it holds anything
([ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)). Inside it, every path the module touches
is in exactly one of four classes, and **the class is visible in the definition from the shape
declared**, not inferred from what happened to be on disk:
| class | declared as | the host's rule |
|---|---|---|
| **owned** | a file with content, or a roster fact | written whole, regenerated, removed when undeclared; a file found there with no record of the mesh making it is kept once before it is written over ([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)) |
| **written into** | a file written *into* a structured document | only the keys the definition names are set, every other key is kept, and each set key is given back when undeclared (ADR 0102). The key list is the module's and is short |
| **written by the module's own process** | nothing the host applies: the module's code writes it from what it was handed | the file's content is never a declared file's content, because a declaration travels in the clear on the bus and the host records it; the module's code writes it, owned by the account, atomically. [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) says how for a credential |
| **found** | nothing | never read, never rewritten, never removed. The person's memory, history, projects, local settings, their own rules and skills |
**A file the tool writes for itself is written into, never over.** The agent's settings file and its
own state file are the tool's; the mesh has one or two facts to state in each. Setting those keys and
nothing else is what lets a person's `/model` choice survive a push, and what lets the mesh's keys be
taken back cleanly when the module goes.
**A predecessor's output is found.** A file placed by a generator that no longer exists is, to the
mesh, a file it has no record of making. Where the successor module keeps the path, declaring it
*adopts* it: the host keeps the original once and writes the mesh's. Where the successor does not keep
the path, the mesh does not remove the file, because it removes nothing it did not make; **the operator
removes it, once**, and the module's definition names those paths in its own documentation so the step
is not forgotten. This is the first instance of the one-off setup step to-be 29 leaves open, and the
rule chosen for it is that it is a person's act, listed, not a module's.
**The rule is the family's.** An ssh client module, a shell module, an agent module each declare their
directory and classify their paths this way. A module that cannot say which class a path is in has not
finished its definition.
## Consequences
- A person's work under their home survives every push and every unassign. The mesh's own files come
and go with the module; the mesh's keys in the tool's files come and go with it; the directory stays.
- **Stale files survive too.** Two workstations keep three predecessor files each until a person removes
them — a visible cost, accepted over a mesh that deletes under a person's home. A module author who
renames one of the mesh's own files has the ordinary path: the old resource id is undeclared and the
host removes what it made ([ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md)).
- A module's definition is longer by a classification, and a reviewer has one more question per path.
That is the point: *which parts are managed must be explicit rather than inferred* (to-be 15).
- **What got harder:** a module cannot seed a person's preference once and leave it. A seeded file
([ADR 0087](0087-a-seeded-file-is-created-once.md)) is the shape for that, and it is available to
this family unchanged; what is refused is a seed the module later wants to change, because what grew
in it is the person's.
## How it is checked
| Rule | Checked by |
|---|---|
| The directory is created owned by the account and kept when the module goes | host tests of a directory resource with an owner (ADR 0051's and 0118's), and the family's lab check below |
| An owned file found with no record is kept once, then written | host tests of ADR 0102's kept-original rule |
| Only the declared keys of a written-into file change, and are given back | host tests of ADR 0102: declared keys set, the rest kept, restored when undeclared |
| Nothing found is touched | the family's lab check: a machine with a seeded home holding a person's file beside a predecessor's; after apply the person's file is byte-identical, the predecessor's is kept as the original, the mesh's keys are set and the person's keys in the same file remain; after unassign the mesh's files are gone, the keys are restored, the person's files are untouched and the directory stands |
| Every path a home-scoped module touches is classified | a catalogue review rule for this family: each path is a directory, a file, a file written into, a secret-and-step, or absent — the first module written to it is [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) |
## References
- [to-be 29 §3](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the same boundary drawn for `~/.ssh`
- [to-be 15](../03-DESIGN/01-to-be/15-the-agent-session.md) — a session's memory is declared by nobody
- [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0087](0087-a-seeded-file-is-created-once.md),
[ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md), [ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md) — the mechanics each class rests on
- [ADR 0051](0051-shared-data-is-the-operators.md) — the third case the host had no word for: what it neither made nor configured
- the predecessor's `claude-code` module manifest, whose comment on `strategy: merge` records the reverted `/model` choice
@@ -0,0 +1,163 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0024-model-access-is-a-provision.md
---
# 183. 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
## Context
**The operator's stance, set on 2026-10-02 and sharpened during the day.** The controller has no part in
the agent module. The host is module-agnostic: it knows no vendor, no agent, no path under a home. The
agent module owns its own files. And there must be a *real* licence manager — a module that doles out
the correct licence in every situation the mesh has: two subscription accounts and one API key today,
used by a person's interactive agent on each workstation, by the mesh's own sessions, and by workers.
**What the predecessor built, read from its code the same day.** Two modules, split after an incident.
A *manager* on exactly one node held every account's full OAuth grant encrypted, rotated each grant
under a per-licence lease on a cadence and an expiry floor, published each rotation over its bus with
the tokens encrypted, collected the vendor's usage figures per licence, and alerted once a day on
repeated failure or on a refresh token within three days of its own expiry. A *consumer* on every node
was the single writer of the agent's credentials file: it applied a published rotation, stripped the
refresh token so a node could never rotate, pulled when stale, refused a stale grant by comparing
expiries within one lineage, and mirrored a local login back to the manager only after checking the
account's identity against the licence's record — because an unchecked mirror had once written one
account's grant into another's row and published it mesh-wide. Three **touchpoints** with fallbacks: the
node's interactive agent; the mesh's own sessions on the node, falling back to the node's licence; a
worker's own account, falling back to the node's, and refusing to spawn when assigned a licence that
could not be served. The split exists because four nodes refreshing one grant destroyed it: an OAuth
refresh rotates the refresh token, and the predecessor's own code records both that a reused token
killed a licence and that a malformed client id was once misdiagnosed as the same fault. **Whether a
refresh token is single-use is not documented by the vendor**; the predecessor treated it as so, and
this record keeps one rotation source for that reason while leaving the fact to be measured.
**What the mesh has.** [ADR 0050](0050-model-access-is-vendor-agnostic.md) put a per-vendor adapter
inside the controller's licences context, with the carve-out that the manager node holds the refresh
token readably; the catalogue has a manager and a consumer module built on it, assigned to nothing. The
controller's licence commands are not seat verbs and cannot be asked for through the console
([to-be 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)). `model-access` is a vendor-blind
provision ([ADR 0024](0024-model-access-is-a-provision.md)), and the operator's judgement is that the
agent is not a vendor-blind consumer: it is coupled to an Anthropic subscription grant and nothing else,
so a name that hides the vendor misdescribes the coupling
([ADR 0027](0027-a-provision-names-what-the-consumer-is-coupled-to.md)).
**The bus's rule for a secret** ([to-be 32 §10](../03-DESIGN/01-to-be/32-what-a-module-declares.md)): the
bus is not trusted with one; a secret travels sealed to its recipient, on core request/reply, never
through a stream that persists it.
## Considered Options
1. **Keep the lifecycle in the controller** ([ADR 0050](0050-model-access-is-vendor-agnostic.md) as
built), and make the agent module a consumer of `model-access` delivered by the host as a sealed
file. Rejected by the operator: the controller and the host would both carry a part of an
Anthropic-specific mechanism, and the agent's coupling is misnamed.
2. **The manager delivers each short-lived token through the vault**, as a backend-issued secret the
vault provides to each consumer ([ADR 0113](0113-the-vault-makes-every-secret.md)). Rejected: every
hourly rotation becomes a vault delivery, a composition and a push to every node, and the host
ends up writing a vendor's credential as a file — the module-agnostic host, carrying a vendor's
traffic.
3. **A seat-holding manager module that talks to the agent module on every node over the bus.**
Chosen.
## Decision
**The Anthropic licence manager is a module, `claude-licence-manager`, holding the mesh-scoped seat
`anthropic-licence-manager`.** The seat's contract is the licence verbs: list the licences and their
health, list the bindings, bind or switch a consumer, release one, refresh now, read usage, adopt a
grant, register a node's key, answer a consumer's current token. One holder, on a node the operator
assigns, is what makes rotation happen once ([ADR 0126](0126-a-module-declares-its-own-seats.md),
[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)). The seat is named for the vendor,
because what it manages is one vendor's grants and nothing else is coupled to it. The vendor-blind
`model-access` provision stands for the consumers that do not care which vendor answers; the agent is
not among them.
**The manager owns the licences.** The records, the grants, the bindings per touchpoint, the usage
readings and the audit of every switch live in the manager's own store, not in the controller's
licences context, which keeps only what it already serves to vendor-blind consumers. The manager is the
one rotation source: it alone calls the vendor's token endpoint, under a lease per licence, on an expiry
floor and a cadence it declares as a setting.
**The long-lived grants are encrypted at rest with a key the vault made for the manager.** The vault
keeps custody of that one key as the manager's own secret ([ADR 0113](0113-the-vault-makes-every-secret.md));
the grants themselves — a refresh token per subscription account, the API key — are the manager's
rows, readable only by it. This is [ADR 0050](0050-model-access-is-vendor-agnostic.md)'s carve-out,
moved with the manager: *one module, one node, the long-lived grants only.*
**The short-lived tokens travel module to module, sealed, on request/reply.** The agent module on each
node makes a keypair of its own when it first runs — a private key made where it is used, never leaving
([ADR 0113](0113-the-vault-makes-every-secret.md)) — and registers its public half with the seat. The
manager hands a node its token by calling that node's agent module (`<module>.<tool>@<node>`,
[ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)) with the token
sealed to that key, and the module answers *applied* or *refused* and why. An agent module that starts,
or finds its token near expiry, asks the seat for its current token the same way. **A token is never
published as an event**: what the manager emits — rotated, switched, failing, usage read — names the
licence and nothing secret, and the audit logger records it. This is a second channel for a secret
beside the vault's, and it is bounded as 0050's carve-out is: this vendor, tokens that live hours, sealed
to one recipient, request/reply only.
**The agent module alone writes what the agent reads.** For a subscription licence it writes the
agent's credentials file under the operator's home, as the operator, access-token-only, atomically. For
the API-key licence it serves the key through the agent's own key-helper setting, so nothing is written
under the home at all. For the mesh's own sessions and workers on that node, it is the local source of
their token. **The host delivers the module's package and its state directory and knows nothing else**:
no path under the home, no vendor, no file shape.
**A binding is explicit, and a switch is a reaction.** Every consumer — a node's interactive agent, the
mesh's session on a node, a worker — is bound to a licence by the operator through the seat's verb, with
the predecessor's fallbacks: a session inherits its node's licence, a worker inherits its node's, and a
worker assigned a licence that cannot be served is refused rather than lent another. Exhaustion is
observed and warned about once per crossing of a declared threshold; moving a consumer to another
licence is a person's act through the seat's verb, as [ADR 0024](0024-model-access-is-a-provision.md)
says, and the declaration language grows no conditional. An automated policy is not decided here.
**A login is attributed only to the account it belongs to.** When a person logs in on a node, the
agent module reads the account's identity from the agent's own state and offers the grant to the
manager sealed to the manager's key; the manager adopts it only when the identity matches the licence
the node is bound to, and refuses with a notification otherwise.
## Consequences
- One module decides which licence every consumer gets, one module writes what each agent reads, and
neither the controller nor the host carries a word of the vendor.
- **A second sealed channel exists** beside the vault's, bounded as stated. A record that widens it to
another vendor or a longer-lived secret is a new decision, not an application of this one.
- The catalogue's `anthropic-manager` and `anthropic-consumer` modules, built on ADR 0050's placement,
are retired once the manager runs; the controller's licences context stops holding Anthropic licences.
- The console lists the seat's verbs, so a person switches a licence in a sentence, and the controller
gains no `licence` verb.
- **What got harder:** a manager that is down leaves every node on its last token until it expires;
the agent module keeps the last token and says so. And a node whose agent module has not registered
its key cannot be handed a token, which the manager reports by name.
- Every interactive session on a machine shares the node's one agent directory, and so its licence;
twenty sessions share it as one does. A consumer with a licence of its own on the same machine is a
worker running from a home of its own with its own agent directory — the worker touchpoint above, for
when workers exist ([ADR 0003](0003-agents-are-persistent-employees.md)); the predecessor ran its
agents that way.
- **Not decided here:** an automated switch on exhaustion; whether a refresh token is single-use, to be
measured in the lab.
## How it is checked
| Rule | Checked by |
|---|---|
| Only the seat's holder calls the vendor's token endpoint | a catalogue test: no module but the manager names it; the manager's refresh runs under a lease per licence, tested with two concurrent runs |
| A token crosses the bus only sealed, only on request/reply | a bus test: every message the manager publishes as an event carries no token; the hand-over is a request whose payload opens only with the receiving module's key |
| The agent module's private key never leaves the node | the per-key test of ADR 0113, extended to this module's key |
| The host writes nothing under a home and names no vendor | a catalogue test on the agent module's definition: no file resource under a home, no vendor word in anything the host applies |
| A grant is attributed only to a matching identity | a manager test: a grant whose account identity differs from the bound licence's is refused and a notification emitted |
| An unservable binding refuses rather than lends | a manager test: a worker bound to a dead licence is answered with a refusal, never another licence's token |
| A switch through the console changes the token on the node and nothing in the answer is a token | a live check on one workstation |
## References
- [ADR 0024](0024-model-access-is-a-provision.md), [ADR 0050](0050-model-access-is-vendor-agnostic.md) — the licence as a named thing, the carve-out this moves with the manager
- [ADR 0027](0027-a-provision-names-what-the-consumer-is-coupled-to.md) — why the seat is named for the vendor
- [ADR 0113](0113-the-vault-makes-every-secret.md) — the vault's custody of the manager's key, and the exception stated here
- [ADR 0126](0126-a-module-declares-its-own-seats.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) — a module's seat, its verbs, a call addressed to one machine
- [to-be 32 §10](../03-DESIGN/01-to-be/32-what-a-module-declares.md) — a secret on the bus
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md), [to-be 39](../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md) — the two modules
- the predecessor's `claude-licences` and `claude-code` modules, read 2026-10-02: the lease, the floor, the lineage comparison, the identity guard, the touchpoints
@@ -0,0 +1,75 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0005-the-node-host.md
---
# 184. A service the mesh asked to run is still running a moment later
## Context
The host already refuses to take a service manager's word for it. Three places in one function read
a unit back after acting on it, each with a comment saying why: *a service manager accepting a
command says the transaction was accepted, not that the unit is running — one that starts and
immediately dies satisfies it.* The intent was right and the implementation did not reach it.
On 2026-10-02 the mesh composed a fail2ban jail whose pattern the daemon refused. The host wrote the
files, restarted the service, read the unit back and reported *restarted*. The unit was `active` at
that instant and `failed` 221 milliseconds later, which the unit's own record states. Both public
machines then kept no bans at all — every jail, not the one at fault — and nothing in the mesh said
so. The fault was found by calling a tool that needed the daemon, not by the mesh noticing.
The read-back races the failure. A service manager returns when it has started the process; a daemon
that reads its configuration, refuses it and exits does so a fraction of a second afterwards. One
look sees `activating` or `active` whatever the process is about to do, and *the host reports success
for a machine that is already wrong* — the one shape of failure this host exists to refuse
([ADR 0005](0005-the-node-host.md)).
A command the module declares — *test the configuration before restarting* — was considered and
rejected. The link carries no actions ([ADR 0005](0005-the-node-host.md)), and a verification
command is a command: a declaration that carried one would be remote execution over the bus,
arriving as root on every machine, which is a far larger door than the fault it closes. The host
does not need one. It already knows what it asked for.
## Decision
**1. A unit the host has just asked to run is read twice**, with a pause between the reads long
enough for a daemon that refuses its configuration to have exited. Not running at the second look is
a failure of that resource, named with the unit and the state it is in — the same failure the single
read was always meant to catch.
**2. It is never a wait for a unit to come up.** A unit still starting reads as running at both
looks and is accepted, exactly as before. What the second look catches is a unit that *was* running
and is not any more. A service asked to be stopped is not waited on at all.
**3. The host tests nothing and runs nothing of a module's.** The second look is the host checking
the state it was told to establish, which is its whole job; the declaration gains no vocabulary, and
no command reaches a machine that did not already come from a built artifact.
## Consequences
- Every apply that starts, restarts or reloads a service spends a moment confirming it. The cost is
bounded by the number of services that changed in that apply, which is usually none.
- A module whose configuration the mesh composes — the packet filter, the intrusion prevention, the
resolver — now fails its apply when the composition is bad, instead of reporting success onto a
dead daemon. `status` names the machine, which is how the operator finds out.
- It does not prevent the bad composition. [ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)'s
manifest check is what refuses the one that caused this, at merge time; this record is what makes
the *next* one visible within a minute rather than invisible until something asks the daemon a
question.
## How this is checked
| Rule | Checked by |
|---|---|
| A unit that is running at the first look and dead at the second fails the apply, naming the unit and its state | a host test over a service manager that answers as systemd does |
| A unit still starting is accepted at both looks | a host test |
| A service asked to be stopped is not waited on | a host test |
## References
- [ADR 0005](0005-the-node-host.md), [ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)
- [Design 05 — The node host](../03-DESIGN/01-to-be/05-the-node-host.md)
@@ -0,0 +1,73 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md
---
# 185. A control plane behind its seat's row serves what it can
## Context
The mesh's own verbs are the controller seat's tools, and the seat's row is the store's
([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)). A control plane reads the
row at start and installs a handler per verb; a verb the row carries that the binary cannot run was
refused at start rather than at the first call, so that a disagreement between the row and the
binary was said early. The refusal aborted the start.
On 2026-10-02 a merge added one verb. The new control plane started, widened the row, and ran. A
push a few seconds later recreated its container at the previous image — a stale declaration from
an overlapping wave, [issue 201](../04-ISSUES/201-a-push-recreated-the-controller-behind-the-row-its-successor-wrote/00-report.md) —
and the older binary read a row naming a word it had never heard. It refused to start, and kept
refusing. The mesh had no voice for ten minutes: no verb answered, no node could be pushed, no build
was dispatched, and `status` said nothing because `status` is one of the verbs that had stopped
being served. The way back was a person running the binary by hand outside its service, because the
push that would have replaced it is itself a verb of the control plane that was down.
The check was right about the fact and wrong about the cost. A row ahead of a binary is the ordinary
state of a roll-out: the row is widened by whichever control plane starts first, and a mesh with one
control plane sees that gap on every merge that adds a verb. Making it fatal turned a transient into
an outage with no path out that did not need a human.
## Decision
**1. A control plane serves the verbs it can run and does not refuse to start for the ones it
cannot.** The row remains the authority on what the seat serves; this is only about what this binary
does when it is behind the row.
**2. A verb it cannot run answers the reason.** Not silence and not a missing subject: a caller gets
a sentence naming the verb, saying this control plane cannot run it and that it is a verb of a newer
build. A verb that is simply absent from the row is still not served at all — that is the row
deciding, which is unchanged.
**3. It says so once at start**, naming every verb of the row it cannot run, so the gap is visible
in the log of the thing that has it rather than only at the moment somebody calls one.
**4. A mesh with no controller seat at all is still a refusal.** That is not a version gap, it is a
mesh that has not been seeded, and nothing this control plane does would be meaningful.
## Consequences
- An overlapping roll-out costs the verbs the newer build added, for as long as the older binary is
in place. Everything else — every push, every build, every read — keeps working, and the ordinary
machinery that notices a machine is behind is what puts the newer binary back.
- The log gains one line on a control plane that is behind, and nothing on one that is not.
- Issue 201's other half remains: the push that sent a stale declaration is a race worth closing on
its own terms. This record makes that race survivable rather than fatal, which is the difference
between a transient and an outage, and is deliberately the cheaper half.
## How this is checked
| Rule | Checked by |
|---|---|
| A row carrying a verb this build cannot run still serves every verb it can, and names the one it cannot | a controller test over a widened row |
| The unknown verb answers a sentence naming itself and saying this build is behind | the same test |
| A mesh with no controller seat is refused | the existing start-up path |
## References
- [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
- [Issue 201](../04-ISSUES/201-a-push-recreated-the-controller-behind-the-row-its-successor-wrote/00-report.md)
- [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)
@@ -0,0 +1,81 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md
---
# 186. A ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang
## Context
[ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md) gave the
public proxy a jail. Within the hour the home server's ban list held `192.168.1.1` — the house's own
router. The router reflects local traffic, so every client in the building reaches that machine as
the gateway's address; one local request for a name the mesh does not serve, three times in a day,
and the whole house is refused by the machine it was asking. The jails inherited an `ignoreip` of
the loopback and the mesh's own range, which was right when the only jail read the ssh daemon and
the only clients were the mesh's; a jail on a public front door sees the neighbours too.
The same jail broke the other half of [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md).
The home server began reading *NOT the mesh alone: 1 rule set the mesh did not write refuses traffic
here*, and the rule set named was the mesh's own ban chain, written by the mesh's own intrusion
prevention minutes earlier. The host's reader of the legacy filter required every path into a chain
of refusals to come from a built-in chain whose policy accepts, before it would call that chain a
ban. On that machine the chain hangs off the container runtime's user chain as well as the input
chain, and the runtime had set the forward policy to DROP — so the mesh reported its own work as a
foreigner's, on the one machine where the group's exit condition was supposed to hold.
Both faults are one mistake in two places: a rule written about the public internet, applied to
everything that arrives.
## Decision
**1. A ban list never holds a neighbour.** The jails the mesh composes never ban a source on a
private range — the mesh's own range, which was already named rather than written
([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)), and every address space
reserved for private use beside it, in both families. A machine behind a router that reflects local
traffic sees its whole building as one address; a ban there is a self-inflicted outage, and the
sources worth banning are not on those ranges in the first place.
**2. The mesh's own bans are its own wherever they hang.** A chain of refusals is a ban list when
every refusal names the sources it refuses and the chain accepts nothing — the rule the host already
applied to the packet filter's own tables, now applied to the legacy filter too, and nothing more.
The policy of the chains that jump into it says nothing about what it is: that policy is already
classified where it belongs, as the container runtime's, and requiring it here counted it twice.
**3. A chain that accepts anything is still not a ban.** That is what keeps a predecessor's
allow-these-and-drop-the-rest chain classified as something an operator must look at, which is the
distinction [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md) exists to draw.
## Consequences
- The composed jails gain the private ranges in their never-ban list. An address already banned
stays banned until it is released; the house's router was released by hand the moment it was found.
- The home server reads *the mesh alone* again, which is group 7's exit condition and was false for
about an hour.
- A machine whose apply fails for an unrelated reason does not revisit its found firewall's record
at all — the step runs only after a clean apply ([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)).
The home server's record therefore still reads *retired by the mesh* although the front end is
uninstalled, and will correct itself once that machine's own stuck module is fixed. It is a stale
record, not a wrong machine.
- The record number the front end's removal was given moved under it: another session took 0175
while that record was in review, and it is now
[ADR 0180](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md). The citations
the host and the control plane print were pointing at an unrelated record and are corrected here.
## How this is checked
| Rule | Checked by |
|---|---|
| A private source is never banned | the module's jail configuration, read back by `fail2ban.fail2ban_settings` on a machine |
| The mesh's own ban chain reads as a ban behind a dropping forward policy | a host test over the home server's own captured rule set |
| A chain that accepts anything is not a ban | a host test |
| Live | the home server reads *the mesh alone*; no ban held on either machine is a private address |
## References
- [ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md), [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0180](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md)
- [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md), [Design 31 — A module declares its fail2ban jail](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
+17
View File
@@ -178,6 +178,15 @@ python3 00-META/checks/index.py fail if stale
- **0162** — [A merge produces a tiered plan the mesh keeps, and a module's dependencies are one relation in the catalogue](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
- **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)
- **0179** — [The intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)
- **0180** — [The found front end is uninstalled once a machine is converged](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md)
- **0184** — [A service the mesh asked to run is still running a moment later](0184-a-service-the-mesh-asked-to-run-is-still-running-a-moment-later.md)
- **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)
### Its tiers, from the bottom up
@@ -268,6 +277,13 @@ 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)
- **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)
- **0177** — [A unit may be user-scoped, and the service manager is a node seat whose holder answers for the units](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)
- **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)
### How it is built
@@ -289,6 +305,7 @@ python3 00-META/checks/index.py fail if stale
- **0107** — [Persistent data is a directory bind, never a named volume](0107-persistent-data-is-a-directory-bind-never-a-named-volume.md)
- **0111** — [A build source is on the mesh's git seat, or it is an external repository](0111-a-build-source-is-on-the-git-seat-or-external.md)
- **0149** — [The live mesh is the test bed](0149-the-live-mesh-is-the-test-bed.md)
- **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)
### How it is checked
+22 -3
View File
@@ -1,9 +1,10 @@
---
layer: to-be
status: in-progress
code: [mesh-lab]
updated: 2026-09-11
code: [mesh-lab, mesh-catalog modules/lab]
updated: 2026-10-02
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
---
@@ -119,7 +120,25 @@ In order, on a machine with nothing:
6. **Verification**, as above, before anything is raised.
## Open
## 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.
- **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
+24
View File
@@ -4,6 +4,8 @@ status: in-progress
code: [mesh-host]
updated: 2026-10-02
decisions:
- 02-DECISIONS/0184-a-service-the-mesh-asked-to-run-is-still-running-a-moment-later.md
- 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
- 02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
@@ -176,6 +178,14 @@ has left to say. *How it is checked:* a host test joins a kept network and refus
host test keeps a left-out module's record and hold and removes an absent module's; a bootstrap test
holds the installer's constants to the module's manifest where the catalogue is checked out beside it.
**What filters the machine, and the found firewall kept retired** — revision, 2026-10-02
([ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)). The host
reports, with every apply, every table and legacy chain that refuses traffic and whose it reads it as —
the mesh's, the found firewall's, the container runtime's own, a ban, or other — and, converged, whether
the firewall it was found with is in force and who retired it. It retires that firewall on every
converged apply, not once, records *found inactive* apart from *disabled by the mesh*, and says when the
step was skipped. *How it is checked:* ADR 0168's table.
**Found reaches every kind that can touch what the machine has**
([ADR 0103](../../02-DECISIONS/0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md)). For a module not yet taken, a directory present with no record
keeps its mode and owner, a unit present with no record keeps its state and boot setting, a
@@ -489,3 +499,17 @@ run and reported; the exit follows an in-flight apply rather than interrupting i
not start is rolled back once and the second failure halts; a completed reconcile retires what is older
than the predecessor and never the predecessor; and the newest of two delivered versions is the one
that runs.
## A service is still running a moment later, 2026-10-02
[ADR 0184](../../02-DECISIONS/0184-a-service-the-mesh-asked-to-run-is-still-running-a-moment-later.md).
The host has always read a unit back after acting on it, because a service manager accepting a
command says the transaction was accepted and nothing about the process. The read raced the failure:
a daemon that refuses the configuration the mesh just wrote exits a fraction of a second after the
manager returns, and one look sees it alive. So the host looks twice, with a pause between, and a
unit that was running and is not any more fails its resource by name. A unit still coming up reads
as running at both looks and is accepted; a service asked to stop is not waited on.
No command for this reaches a machine. A module declaring *how to test my configuration* was weighed
and refused: the link carries no actions, and a verification command is one. The host is checking
the state it was told to establish, which is what it is for. *How it is checked:* ADR 0184's table.
+68
View File
@@ -9,6 +9,10 @@ code:
- mesh-host internal/apply (the service that reflects a rule set)
updated: 2026-10-02
decisions:
- 02-DECISIONS/0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md
- 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
- 02-DECISIONS/0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md
@@ -172,6 +176,28 @@ 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
@@ -710,6 +736,48 @@ needs no new filter; a declared port is reachable from off the private network a
not; no address of a machine's own networks appears in a rendered filter, asserted on the text; and a
machine reporting no outward link is refused in the control plane with its existing filter left alone.
### A converged machine is filtered by the mesh alone, and the host says what else refuses
*2026-10-02, [ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md),
from [issues 143](../../04-ISSUES/143-converging-does-not-retire-the-firewall-it-found/00-report.md) and
[144](../../04-ISSUES/144-the-predecessors-rules-outlive-the-firewall-it-was-found-as/00-report.md).*
Retiring the found firewall was a step the flip took once, and said it had taken whatever happened;
on the first machine with one it did not take, and a hand's work fifty minutes later was recorded as
the mesh's. And "the firewall found" named one front end while a predecessor's chain in the container
runtime's user hook — legacy iptables on one machine, invisible to a reader of nftables — filtered the
forwarded path, refused ports the mesh declared open, and carried an allowance every module reaching
another by the machine's own name relied on.
**Convergence is a state the host keeps.** Every converged apply reads whether the found firewall is in
force; enabled again, it is retired again and said; the record says whether the mesh disabled it or
found it inactive, and a skipped step is said. **The host reports what filters the machine**, every
apply, adopted or converged: every table and legacy chain that refuses, with an owner — the mesh's,
the found firewall's, the runtime's own plumbing, a ban, or *other*, which is where the runtime's user
chain's refusals go. **The mesh says which:** `node show` lists them; `status` names a converged machine
anything *other* filters and is not well; the converge preview lists what filters the machine and the
fate of each — retired with the front end, left as the runtime's, left as a ban, or *left in force and
not the mesh's*. The mesh removes none of it; adoption's threshold does not move.
*How it is checked:* host tests over rulesets captured from three machines of this mesh classify every
refusing chain (a predecessor's chain in the legacy filter as *other*, a ban list reached through the
user chain as a ban, a leftover front-end chain as *other*); a fake front end enabled again on a
converged machine is retired again and said, found inactive is recorded as found; the report carries
the filters and the found firewall's state and a change in them is worth an unasked report; controller
tests over a fixture report check the recording, the preview's fates, the status JSON and the well
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.
*2026-10-02, [ADR 0180](../../02-DECISIONS/0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md):*
once a machine is converged, the front end it was found with is uninstalled, not merely disabled — the
packet filter's holder declares its package absent after the mesh's filter is loaded, and a return to
adopted then enables nothing. The rollback path ADR 0100 kept on disk is given up on purpose.
## 5 — Certificates
**Two authorities, kept separate on purpose.**
+10 -1
View File
@@ -4,9 +4,10 @@ status: in-progress
code:
- mesh-controller internal/licences
- mesh-controller cmd/mesh-controller/licence.go
updated: 2026-09-05
updated: 2026-10-02
decisions:
- 02-DECISIONS/0024-model-access-is-a-provision.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 02-DECISIONS/0009-modules-and-the-graph.md
- 02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md
- 02-DECISIONS/0055-model-access-is-answered-by-a-licence-or-a-node.md
@@ -96,6 +97,14 @@ So `(node, module)` tells them apart, and asking for a licence per session neede
identity. Checked rather than argued: two sessions on one machine hold different licences, each is
given its own key, and releasing one leaves the other.
*2026-10-02:* the operator's own agent at a terminal is **not** a consumer of this provision: it is
coupled to an Anthropic grant and nothing else, so it uses the `anthropic-licence-manager` seat, whose
holder owns the Anthropic licences, their bindings and their rotation, and hands each node's agent its
token over the bus
([ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md),
[36](36-the-operators-agent-on-a-machine.md), [39](39-the-anthropic-licence-manager.md)). This
provision stays for the consumers that do not care which vendor answers.
**What is still open is the rest of the gap, and it is the harder half.** A *worker* is not one
per machine — many can run on one, from one module — so `(node, module)` cannot name them apart
and this reasoning does not extend to them. That belongs with
@@ -1,9 +1,14 @@
---
layer: to-be
status: proposed
code: []
updated: 2026-09-27
status: in-progress
code:
- mesh-controller internal/inventory
- mesh-controller internal/catalogue
- mesh-controller cmd/mesh-controller
updated: 2026-10-02
decisions:
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md
- 02-DECISIONS/0051-shared-data-is-the-operators.md
@@ -14,10 +19,10 @@ decisions:
# 29 — A node has operator accounts, and the mesh owns what lives under a home
**The mesh models machines but not the people on them.** A node record holds its name, its
address, its mode — and nothing about *who a person is* on it: `jochens` on novox, `ace` on ace,
`jochen` on shanks and g14. That username is not incidental. It decides who a file under `~` is
owned by, who a user service runs as, and — the case that surfaced this — which account `ssh
<node>` logs in as. The predecessor knew it (its per-node `user:`, and the modules that wrote a
address, its mode — and nothing about *who a person is* on it: one login name on the build node,
another on the home-server, a third on both workstations. That username is not incidental. It
decides who a file under `~` is owned by, who a user service runs as, and — the case that surfaced
this — which account `ssh <node>` logs in as. The predecessor knew it (its per-node `user:`, and the modules that wrote a
person's `~/.ssh/config`, `~/.zshrc`, `~/.config`); the mesh, taking those over, kept the machine
facts and dropped the human one.
@@ -28,8 +33,9 @@ Several things are missing, and they are one idea.
A node has one or more **operator accounts**: the human logins on it. At minimum a name; the
mesh already knows the node and its address, so `<account>@<node>` is then a complete answer to
"who am I, where." It is the mesh's to hold because everything below is derived from it, and
because it is exactly the fact that was silently lost — `ssh ace` failed to `ace` because nothing
in the mesh said ace's account is `ace`.
because it is exactly the fact that was silently lost — `ssh home-server` logged in under the
workstation's own name, because nothing in the mesh said the home-server's account is a different
one.
## 2. A resource may live under a home, owned by its account
@@ -65,14 +71,14 @@ create `~/.ssh` at `0700`, chown it to the account, and own the files it places
**The boundary — and it is the reason this is safe:** `~/.ssh` is the one directory where a wrong
declaration locks a person out of their own machine. So the mesh's *found-vs-owned* semantics
([ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md),
([ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md),
adoption) apply *inside* the home directory. The mesh **owns** the directory and the files above; it
**holds as found — never rewrites, never removes** — the operator's own contents: their **private
keys** and their **personal drop-ins** (`config.d/personal`, the personal `Host` aliases a
workstation carries, exactly as `hosts.local` is the home the mesh never rewrites for `/etc/hosts`).
Reconcile removing an unassigned `config.d/mesh` is fine; the same logic aimed at `id_ed25519` or an
operator's own `authorized_keys` entry is a lockout. This is the login-channel cousin of the rule
[ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd
[ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd
module draws for the firewall: **the mesh must never be able to arrange the one failure that severs
its own way back in.** The carve-out is not a convenience; it is that rule, in `~/.ssh`.
@@ -108,12 +114,12 @@ found-vs-owned boundary of §3 is exactly what guarantees nothing already there
None of this needs a node to discover the mesh, and none of it needs a control-plane module of its
own. The ssh files are **roster facts**
([ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the
([ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the
roster view carries a node's **host key** and its **account** beside its name and address, the
`ssh-client` module ships a template for `known_hosts`, `config` and `authorized_keys`, and the
controller renders each node's copy from the full roster and pushes it. The mesh owns the data; the
module owns ssh's format; the control plane gains no ssh syntax. It is the same act as composing a
peer list or `/etc/hosts` — which is why there is **no novox-only "mesh-ssh" module**: the
peer list or `/etc/hosts` — which is why there is **no control-node-only "mesh-ssh" module**: the
centralization is the controller's composition, not a module that runs somewhere. Only non-secret
facts travel (names, addresses, accounts, host keys, the CA public key); the private key stays the
operator's, placed as an operator-owned file, referenced by path.
@@ -129,6 +135,53 @@ operator's, placed as an operator-owned file, referenced by path.
They meet at the account and the CA, not at a bespoke module. The `sshd` server side already exists;
the client/identity side and the CA are the open pieces.
## What has shipped, and what has not
*Recorded 2026-10-01 from the controller's main branch, not from intent.*
**Built (mesh-controller, merged 2026-09-27):**
- **§1, the account as a node fact.** A node record carries an operator account and, optionally,
its home. Empty is a real state — a freshly enrolled or headless machine has no operator account
known yet — and an empty home means *derive it* (the superuser's home for the superuser, the
conventional per-user home otherwise), so the common case needs no entry. The controller's node
command sets it. One account per node is what exists; "one or several" below is still open.
- **§2, resources under a home.** The account and its home are offered as machine facts, and a
resource's *path and owner* resolve placeholders exactly as its content does — so a module places
a file under a person's home, owned by that person, naming neither. A roster file may say it lives
under the home: it is rendered per node, placed under that node's account's home, chowned to the
account, and a node with no account gets none.
- **§5, the composed ssh config.** The roster rendering carries each node's account, so the
`ssh-client` template can emit a `Host` block per node with the right login name. Composed
end-to-end in the controller's tests.
**Written but not shipped:** the `ssh-client` catalogue module itself exists on a branch of the
module repository; its pull request was closed with a hold until this design is deployed, and
nothing has deployed it since. The predecessor's generator still writes every workstation's ssh
client blocks today — which is where [issue 172](../../04-ISSUES/172-the-ssh-client-block-matches-one-spelling-of-a-machine/00-report.md)
was found.
**Not built:** the SSH CA and certificates (§4), `known_hosts` and `authorized_keys` as roster files,
the found-vs-owned boundary inside `~/.ssh` (§3 — the controller has no rule yet that refuses to
rewrite a private key), adoption of existing keys, the ssh-agent as a user service, and user-scoped
services in general. The host vocabulary still has no user-scope unit at all; a workstation's
per-user daemons (a bar watchdog, a config reloader, an audio service masked per user) have no form
the mesh can send.
**A gap this surfaced:** §1 shipped as code before it had a decision record. The account as a node
fact, the home as a placement root, and what the mesh may and may not do under a home are each a
decision this document names but no record states. They are the next records to write, before the
family of §2 modules is built.
*2026-10-02:* two of them are written. [ADR 0181](../../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)
records the account as a node fact and the home as a placement root, reconstructed from what shipped;
[ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
generalises §3's boundary to every directory under a home. The first member of the §2 family is
designed in [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md). User-scoped
units are [ADR 0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md),
written the same day; still unwritten: several accounts per node, and the CA. On the same day every node of the
live mesh still carried an empty account.
## Why now, and why not yet
**Why it matters:** when HAL retires, the generators that keep `~/.ssh`, shell config and the
@@ -137,7 +190,7 @@ alias and its trust, and a fresh machine has no operator dotfiles at all — the
service and leave the human unable to work on the box.
**Why not build it reflexively:** it is a real addition to the node model, the resource model, and
the seat set, and must be gotten right. The mechanism half is now settled — ADR 0128 is what lets
the seat set, and must be gotten right. The mechanism half is now settled — ADR 0120 is what lets
the ssh files be templates with no control-plane format — so what remains to decide here is the
model:
@@ -156,15 +209,22 @@ model:
unnecessary, and forwarding an agent into a node exposes the operator's keys to that node's root —
so prefer certificates and `ProxyJump` over forwarding.
**Not urgent, not blocking.** ssh and dotfiles work today because HAL's generators still run as the
substrate. This becomes load-bearing in the node-by-node retirement phase, not before — which is the
right time to build it, once the account and CA model are decided here.
**Now load-bearing.** The migration of every node to the mesh is complete; what remains of the
predecessor is exactly the user environment this design covers — ssh config, dotfiles, the desktop
stack and the per-user services of the two workstations. Those generators are the last thing
keeping the predecessor running, so the model questions above are no longer deferred: the account
record, the home as a placement root, user-scoped services and the one-off steps a hook used to run
each need a decision before the modules that replace the generators can be written.
## The family beyond `~/.ssh` — 2026-10-02
The modules §2 calls *a family* — the shell, the terminal, the desktop, everything under a home that is not `~/.ssh` — are designed in [37 — The operator's machine](37-the-operators-machine.md), under [ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) to [0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md). This document keeps `~/.ssh`, the CA and the roster files. Two things it listed as not built are decided there: user-scoped services (ADR 0177) and the one-off steps a hook used to run (declared state, or a seat's verb).
## References
- The gap was found generating `~/.ssh/config` from the *HAL* registry (`hal/terminal`'s
postConfigure hook), which the nox mesh has no equivalent for.
- [ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster
- [ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster
fact mechanism that renders the ssh files, format owned by the module.
- [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) — the
system-path placement this mirrors for home paths.
@@ -173,6 +233,6 @@ right time to build it, once the account and CA model are decided here.
- [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md) — the CA key is a secret the
vault makes; [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md)
— short-lived certs as rotation.
- [ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md),
[ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) —
- [ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md),
[ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) —
the never-sever-the-channel rule and the found-vs-owned semantics, applied here to `~/.ssh`.
@@ -1,10 +1,15 @@
---
layer: to-be
status: proposed
code: []
updated: 2026-09-27
status: in-progress
code:
- mesh-controller: internal/catalogue/jails_into.go, internal/catalogue/manifest.go (Jail, Jailing)
- mesh-catalog: modules/fail2ban (jailing, the base and the seat's verbs), modules/mailu, modules/route-proxy, modules/gitea (jails)
- mesh-host: internal/declaration/declaration.go (a container's logging)
updated: 2026-10-02
decisions:
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md
- 02-DECISIONS/0186-a-ban-list-never-holds-a-neighbour.md
---
# 31 — A module declares its fail2ban jail, and the mesh composes them per node
@@ -63,3 +68,38 @@ jail, composed from the postgres module's manifest, without anyone editing a nod
beside)
- mesh-catalog `modules/fail2ban` (the base: sshd, recidive, ignoreip); the service modules
(`postgres`, `mssql`, `mailu`) that will declare jails
## Decided and built, 2026-10-02
[ADR 0179](../../02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)
made this the rule and built it. A module declares `jails` — each a name, the `failregex` of a
failed attempt in its log, and the stanza's own keys — and the fail2ban module declares `jailing`:
the one file the stanzas compose into and the directory each filter lands in. The controller gathers
every assigned module's jails per node into those; the holder's daemon restarts on the composed file.
What made it workable was the log. A container's output went to a file of the runtime's own, under
a path that changes when the container is recreated, so no jail could read a container's service
however it logged. A container now declares `logging: journald`, the host runs it with the journal as
its driver, and a jail reads it with `backend = systemd` and a `journalmatch` on the container's
name — the same way the base's ssh jail has always read the ssh daemon. The first three doors: the
mail front end (every login failure on its proxying ports), the forge (a failed authentication
attempt) and the public proxy (a certificate or request for a name the mesh does not serve, which
the proxy now says in its log). The base is strict — three in a day for a day; twice banned in two
weeks for four — and the mesh's own range stays never banned.
The seat the module holds serves `status`, `banned`, `ban` and `unban`, from a runtime that carries
only the fail2ban client with the daemon's socket shared in; the jails are composed, the ban list is
the daemon's, and both are read through the console.
*How it is checked:* ADR 0179's table.
## What the first jails taught, 2026-10-02
[ADR 0186](../../02-DECISIONS/0186-a-ban-list-never-holds-a-neighbour.md). Within an hour of the
first public jail the home server had banned the house's own router: the router reflects local
traffic, so every client in the building arrives as the gateway's address. The never-ban list now
holds every private range as well as the mesh's own. And the mesh read its own ban chain as a
foreign rule set on that machine, because the chain hangs off the container runtime's user chain and
that machine's forward policy is the runtime's DROP — the reader now calls a chain of source-named
refusals a ban wherever it hangs, as it already did for the packet filter's own tables.
@@ -2,8 +2,10 @@
layer: to-be
status: implemented
code: [mesh-controller, mesh-tools]
updated: 2026-10-01
updated: 2026-10-02
decisions:
- 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md
- 02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.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
@@ -121,6 +123,8 @@ module-specific names that changes the day the forge is replaced.
asked of the module, through a `tools` verb every runtime answers) and lists a role's tools when the
records carry them.
*Amended 2026-10-02 by [ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md):* the module that serves this to an agent is the node tools runtime — one per node, host-side, serving every assigned module's tools as well as answering the person on loopback. The console is its serving mode, renamed. See [37 — The operator's machine](37-the-operators-machine.md) §3.
## 7. Versioning
A seat's tools are an interface and change like one. Additive within a version. A change that would
@@ -169,6 +173,30 @@ 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.
## The intrusion seat's verbs, 2026-10-02
[ADR 0179](../../02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md).
The second node-scoped seat to carry verbs: `node-intrusion-prevention` serves `status` (every jail
with what it watches and holds), `banned` (every address held now, with its jail and when the ban
ends), `ban` and `unban` (an operator's act on the live ban list). The fail2ban module serves them
from a runtime that carries only the daemon's client, the socket shared in from the machine — no
capability, no machine network, since the daemon on the machine does the banning. That runtime is the
per-module container [ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
retires; the verbs and the client are the same code once the node's own runtime loads them as a bundle.
The module's own tool beside them reads one jail's effective settings. *How it is checked:* ADR 0179'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
+11 -1
View File
@@ -2,7 +2,7 @@
layer: to-be
status: implemented
code: [mesh-catalog, mesh-tools, mesh-controller]
updated: 2026-10-01
updated: 2026-10-02
decisions:
- 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
@@ -20,6 +20,8 @@ An agent reaches them over MCP on the machine's loopback; a person reaches the s
installed by hand, nothing is configured with an address, and the mesh knows the surface exists because
it put it there ([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
> **Amended 2026-10-02 by [ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** What this document describes stays true in substance and changes in form: the console becomes the serving mode of the node tools runtime, a host-side process the host supervises rather than a container, which also serves every assigned module's tools from their bundles. The module is renamed `node-tools`. [37 — The operator's machine](37-the-operators-machine.md) §3 is where it now lives.
## 1. What it is
A module, `mesh-console`, in the catalogue. Its image is the tool runtime's own — the client that
@@ -28,6 +30,14 @@ module's credential and listens on loopback. It has no state, no provision, no s
the bus, which it gets the way every module does: a credential the mesh minted for `<node>.mesh-console`,
sealed to the machine, delivered as the module's own secret.
*2026-10-02:* it gains one provision, at node scope — the MCP endpoint on loopback, serving the port
the machine gave it — so that a module whose software must be told where the console is requires that
and is coupled to an endpoint rather than to a module's name
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)). The first
consumer is the operator's agent, [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md) §6;
a machine without the console refuses such a module by name. Nothing else above changes: no seat, no
state, no tools of its own.
Its manifest says three things nothing else in the catalogue says together:
- `invokes: ["*"]` — it calls every tool on the mesh, and the bus grants exactly that publish side;
@@ -0,0 +1,212 @@
---
layer: to-be
status: designed
code: []
updated: 2026-10-02
decisions:
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 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
- 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
- 02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md
- 02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
---
# 36 — The operator's agent on a machine: the `claude-code` module
**The agent a person runs at a terminal, put on the machine by the mesh, instructed by the mesh, pointed
at the console, and holding the licence the manager hands it.** It is a member of the family
[to-be 29 §2](29-a-node-has-operator-accounts.md) names, the modules that touch a person's machine, and
its counterpart is [39 — The Anthropic licence manager](39-the-anthropic-licence-manager.md).
What it replaces: the predecessor's module of the same name and a sibling, which placed six files under
the operator's home. The predecessor is retired; the six files are still on both workstations telling
every session to use tools that no longer exist.
**Three rules shape everything below.** The host is module-agnostic: it installs the package and gives
the module a state directory, and knows no vendor, no agent, no path under a home. The controller has no
part beyond resolving what it resolves for every module. And the module handles its own files: the
mesh's part of the agent's configuration is written by the module's own code, from what the mesh
delivered it and what the manager handed it.
## 1. Where the mesh's configuration lives: the agent's managed directory, not the home
The agent reads a machine-wide, administrator-owned configuration directory under `/etc`, documented
by the vendor: a managed settings file that outranks every user and project setting; a key in it that
adds HTTP tool servers *beside* a person's own without blocking them; and a managed instruction file every
session reads before the user's and the project's. The agent has **no** machine-wide directory for
rules, skills, slash commands or hooks; those exist only under a home or a project.
So the mesh's part of the agent's configuration lives there, **owned whole by the module**, and the home
is left alone. What the predecessor shipped as two rule files and two skills folds into the managed
instruction file and the manager's tools:
| the predecessor placed | becomes |
|---|---|
| `~/.claude/CLAUDE.md` | the managed instruction file: how a session on this mesh works (§3) |
| `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md` | sections of the same file: this node's identity, the repositories' conventions |
| `~/.claude/settings.json`, merged | the managed settings file: the mesh's keys only, outranking nothing a person did not also set |
| `~/.claude/skills/hal-switch-license/SKILL.md` | the manager seat's `switch` verb, listed by the console, and a sentence in the instruction file saying to use it |
| `~/.claude/skills/cleanup/SKILL.md` | nothing; it named the predecessor's forge |
| the console's entry in the agent's user-scope state | the managed settings' tool-server key, from the console's provision (§4) |
**The home.** Under [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
every path under `~/.claude` is *found*, with one exception: the agent's credentials file, which the
module's own code writes for a subscription licence (§5). The person's memory, history, projects, local
settings, their own rules, skills and tool servers are never read or written by the mesh. **The six
predecessor files are the operator's to remove, once, on each workstation**; the module's documentation
lists them, and until they go the agent reads stale instructions beside the mesh's.
## 2. What the module declares and what its code writes
**Declared, applied by the host:** the agent's package (§7); the module's state directory; a facts file
in that directory carrying the node's name, the operator account, the console's endpoint, the module's
settings; the bus, the console's provision, and that it uses the `anthropic-licence-manager` seat.
Nothing under the home, nothing under `/etc`.
**Written by the module's code**, from the facts file and the manager's hand-over, whenever either
changes:
| path | content |
|---|---|
| the managed settings file | the mesh's keys: the tool servers (the console, plus any the operator declared as settings), the attribution trailers, and — for an API-key binding only — the key-helper that serves the key |
| the managed instruction file | §3 |
| the agent's credentials file under the operator's home | for a subscription binding only: the access token the manager handed over, as the operator, readable by the operator alone, atomic, no refresh token |
| the module's keypair in its state | made once, the private half never leaves (§5) |
Writing under `/etc` and as the operator under the home are two escalations the module's code performs
for itself; the mesh does not run the module as root for everyone, and the caller does not know
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
**Which settings keys are the mesh's.** A key is the mesh's when it encodes a rule of the mesh: the tool
servers that reach the mesh, the attribution convention of its repositories, the key-helper a binding
requires. The model, the spinner, the drafts and every other preference are the person's, and the
predecessor's experience with the model key is the evidence: a mesh that sets a preference reverts a
person's choice on every push.
## 3. What the instruction file says
Prose, not a paste; the file is the module's.
**How a session on this mesh works.** The console is the only path to the mesh, and its tools are the
vocabulary: the record is asked through the records module, symptom first — the literal error text before
a hypothesis ([ADR 0153](../../02-DECISIONS/0153-the-record-is-read-by-a-module-and-the-console-lists-it.md));
the mesh is asked and changed through the controller seat's verbs; the forge through the forge module's
tools; a licence through the `anthropic-licence-manager` seat's verbs, never by editing a file. The hard
rules in new words: a file the mesh manages is changed through the verb that owns it or through the
catalogue, never on disk; a store's database is never written by hand; main is never pushed; the mesh
creates no symlinks and nobody else does; a package is declared, not installed by hand. The glossary's
words, none of the predecessor's.
**Who this node is.** The node's name, from the facts file; the node's role, from the module's settings
on the node's layer; and that the other nodes are asked of the controller's `nodes` verb rather than
listed here, because a table is a copy that drifts.
**The repositories' conventions.** Concise commit messages in the imperative, about why; a branch, a
pull request and a human approval for every merge; test before pushing, because nodes update unattended;
the playbooks in the record.
## 4. The console
The module tells the agent where the console is, and the port is the console's to say. **The console
provides a node-scoped provision** — its MCP endpoint on loopback — serving the port the machine gave
it, and the module requires it. A requirement names what the consumer is coupled to
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)); co-location
resolves it; a machine without the console refuses the module by name. [To-be 34](34-the-console.md) is
amended in the same change; issue 192 (open) found the gap.
**Other tool servers** a person wants on every machine, or on one, are a declared setting of this module
— mesh layer or node layer — rendered into the same managed key. A module tool, `mcp_configure`,
validates a server and sets the setting through the controller's settings verb, so the list stays
declared state. The agent's own HTTP-only constraint for managed servers applies; a person's local
command-based servers stay their own, in their own file.
**The entry's name is `mesh`.** The hand-made entry both workstations carry today is named after this
installation, which a definition may not be ([ADR 0155](../../02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md));
it is the person's to remove, and until then the agent sees the mesh's tools twice.
## 5. The licence: the consumer side
[ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
decides it; to-be 39 is the manager's half. This module:
- **makes a keypair** in its state the first time it runs and registers the public half with the seat;
- **serves `apply`**: the manager's hand-over, a token sealed to the module's key, with the licence's
name and kind. A rotation of the same licence is applied only if newer within one lineage; a switch is
applied regardless, because across licences the expiries are unrelated. The answer says applied or
refused and why, and never echoes a token;
- **pulls** at start and when its token nears expiry, by the seat's `current` verb, and keeps the last
token when the manager does not answer, saying so;
- **writes** for a subscription licence the credentials file as the operator, access-token-only; for the
API-key licence sets the key-helper in the managed settings to a small program that prints the key
from the module's state, so no file under the home is touched;
- **offers a login to the manager**: when the credentials file changes by a person's login, it reads the
account's identity from the agent's state file and offers the grant to the seat, sealed to the manager's
key, for adoption; the manager decides;
- **serves `licence_status`**: which licence and kind this node holds, when the token expires, whether
the file matches what was handed over — by fingerprint, never by value.
Switching is the seat's `switch` verb, asked through the console; this module only applies what it is
handed.
## 6. Scope, settings and the order of assignment
**Every node with an operator account** ([ADR 0181](../../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
None has one today; the operator states them first. **Per node:** the role. **Per mesh or per node:**
extra tool servers. **Prerequisite:** the manager holds its seat and has adopted the licences.
**Order:** the manager assigned and a refresh observed; the console's provision in the catalogue; this
module on one workstation; the six predecessor files and the hand-made console entry removed there; a
new session read to confirm it sees the mesh's instruction file, the console's tools under `mesh`, and
its licence; then the rest.
## 7. The package
The module declares the agent's package. The distribution every node runs does not carry it in its
repositories: the two workstations have it from a build the predecessor's helper made from the community
repository, and nothing updates it since. On those two the declaration is satisfied. **On a fresh machine
the host's package manager refuses it, in its own words, and the module is not applied there.** The
answer is a package repository for this ecosystem as a seat
([ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md)), fed by the builder
and trusted by every node's package manager; not built, and not this module's to build. The vendor's own
installer is rejected: it puts a self-updating binary under the person's home, invisible to the mesh.
## How it is checked
| Check | Defends |
|---|---|
| the module's definition names no node, path or login, declares nothing under a home or `/etc`, and no file resource carries a secret | ADR 0112, ADR 0155, ADR 0183 |
| on a lab machine with an account and a seeded home holding a person's rule file and the predecessor's leftovers: after assign, the managed directory holds the mesh's files, the home is byte-identical except the credentials file, which is owned by the operator and names no refresh token; after unassign, the managed directory's files are gone and the home is untouched | ADR 0182, the host's agnosticism |
| on a lab machine with no account, the assignment is refused naming the fact | ADR 0181 |
| a switch asked of the seat through the console changes the licence and the token on the node; no tool answer and no log line holds a token | ADR 0183 |
| the API-key binding writes nothing under the home and the agent authenticates through the helper | ADR 0183 |
| the console's provision resolves by co-location; a machine without the console refuses the module by name | ADR 0027, ADR 0152 |
| a new session on the assigned workstation lists the console's tools under `mesh` and answers "which node am I" from the instruction file | the exit of the build |
## What this does not settle
- **Several operator accounts on one node** (ADR 0181 decides one).
- **A worker's own licence on a machine.** Every interactive session shares the node's one agent
directory and its licence, however many run. A worker runs from a home of its own with an agent
directory in it, bound to its own licence through the manager (to-be 39 §5); that is for when workers
exist, and nothing here changes for it.
- **The package repository seat** (§7).
- **How the module's tools are run** is decided: the node's tool runtime, host-side
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
The managed files and the credential write are tools of this module that runtime serves. Until the
runtime exists on every node, the module's code runs as a supervised process of its own
([ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)),
which changes nothing in what it writes.
## References
- [ADR 0181](../../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md),
[ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md),
[ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the decisions
- [39 — The Anthropic licence manager](39-the-anthropic-licence-manager.md), [34 — The console](34-the-console.md), [29 — A node has operator accounts](29-a-node-has-operator-accounts.md)
- [research 018](../../01-RESEARCH/018-the-operators-machine-as-modules/00-overview.md) — the operator's machine as modules, and where tools run
- the vendor's documentation on managed settings, managed tool servers, the managed instruction file and the key-helper, read 2026-10-02
- the predecessor's two modules and the six files on the workstations, read 2026-10-02
@@ -0,0 +1,142 @@
---
layer: to-be
status: in-progress
code: [mesh-host, mesh-controller, mesh-tools, mesh-catalog]
updated: 2026-10-02
decisions:
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
- 02-DECISIONS/0040-what-a-module-is.md
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
- 02-DECISIONS/0161-what-deserves-a-seat.md
- 02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md
---
# 37 — The operator's machine
**Every configurable thing on a node is a module, the home included, and the same catalogue serves
a server and a laptop.** One default configuration per module, varied per node by a setting or a
kept region; roles a machine has once as node-scoped seats with tool contracts; one tool runtime
per node serving every module's tools on the host side
([ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md)
to [0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)).
This is the design [to-be 29](29-a-node-has-operator-accounts.md) §2 called *a family* and
[research 018](../../01-RESEARCH/018-the-operators-machine-as-modules/00-overview.md) measured.
## 1. What a module of the environment looks like
Worked on the first one, a shell. The `zsh` module declares:
- a **package**, `zsh`;
- **files under the home**, owned by the account: the shell's rc file with the module's default
configuration, carrying a kept region for the operator's own lines, and `${setting:…}`
placeholders for the few values a node varies; the account and its home are machine facts the
controller resolves ([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md),
to-be 29 §2);
- a **seat declaration**, `login-shell`, node-scoped, with its one verb; and a **claim** on it;
- a **`user` shape** naming the shell, applied only where the module holds the seat;
- a **tools bundle**, the artifact kind for interpreted code, with `execute` and the module's own
`show-config`.
No container, no unit, no service. It is assigned to every node with an operator account. The
`fish` and `bash` modules are the same with another package and other files; one of the three
holds the seat on each node.
The second shape is **system scope**: the login manager declares a package, two files under
`/etc`, and a service, which is exactly what the ssh daemon module declares today. The third
shape is **graphical**: the window manager declares a package, files under the home, a
user-scoped unit or two, a claim on the display-session seat, a dependency on the display server
being held, and a bundle with its tools. Nothing in any of them says which machine it is for.
## 2. Variation
A node differs from the default in two ways and no other
([ADR 0174](../../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)):
a **setting** the module declared, set in the node's layer and rendered into the file; or lines in
a **kept region** the file marks. The predecessor's ninety theme variables become the settings of
the modules whose files read them. Until the settings record proposed alongside the
container-runtime records ships — a setting names the file it lands in — environment modules carry
defaults in their files and declare no setting; that is the order, not a preference.
## 3. The node tools runtime
One per node, started and restarted by the host as a sibling process, never a container
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
It is the tool runtime that exists, in the role it was written for: it reads the memberships of
every module assigned to the node, loads each module's tools bundle, and serves every tool and
every held seat's verb on the subjects issued. It holds the node's one bus credential and may call
every tool on the mesh. Its serving mode on the machine's loopback is what the console was
([to-be 34](34-the-console.md)); the module is renamed **node-tools** and declares the interpreter
it needs as a package.
A bundle reaches the node as any artifact does. A push that adds or replaces one is a reload. A
bundle that fails to load is named in the node's report and the others serve. A tool that needs
root escalates itself.
## 4. The seats of the environment
Decided now: **`login-shell`** (module-declared; zsh, fish, bash; verb `execute`) and
**`node-service-manager`** (the mesh's own; systemd; verbs over units in both scopes). The rest
are candidates from [research 018](../../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md),
one record each when its first holder is written: display server, display session, terminal
emulator, launcher, notifier, compositor, lock screen, bar, login manager, audio, clipboard, boot.
Editors, browsers, media players, the agent, the downloads and scripts folders are modules with
tools and no seat.
A module that needs a role filled depends on **the seat being held** on the node, not on a
capability: the window manager needs the display server seat held, by xorg or by a compositor
that is its own server. Whether a held seat can gate an assignment is the first question the
resolver is asked by the second graphical module; the display server itself is gated by the
`graphical-session` capability the profile already reports.
## 5. What the host gains, and what it does not
- `service` gains `scope: user`, applied as the account
([ADR 0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)).
- The host starts and supervises the node tools runtime as it would any host-side process, and
delivers bundles as artifacts.
- Nothing else. No hooks, no actions: `chsh` is the `user` shape, enabling a unit is the `service`
shape, rebuilding boot images is a verb of the boot seat when that seat is written.
- A gap, recorded: the `package` shape drives the distribution's package manager and nothing
outside its repositories. The login manager in use is such a package; it waits on an official
package or a decision the host does not yet have.
## 6. The order of the build
1. **The operator account on every node** — `mesh-controller node` with the login name; empty on
all four today. Nothing home-scoped composes before it.
2. **The node tools runtime** — mesh-host supervises it; mesh-tools serves bundles from memberships
and reloads; mesh-controller composes the bundle into the declaration and the memberships to one
runtime per node; the catalogue renames the console. Proven when the packet-filter verbs answer
from it and its container is gone.
3. **`zsh`**, the first environment module: seat, `user` shape, home files, `execute`. Proven on a
server first, then every node.
4. **`systemd`** and user scope: the host's field, the seat seeded, the module. Proven by the
desktop's reload watcher declared `scope: user` on a workstation.
5. **The login manager**, system scope, once its package is installable; then the display server,
the window manager, and the rest of the graphical stack, each seat its own record.
6. **Settings** for the theme knobs, after the settings record ships and issue 168 closes.
## How it is checked
| Claim | Checked by |
|---|---|
| A module with a package, home files, a seat and a bundle resolves and composes on a node with an account, and is refused on one without | the controller's composition tests |
| One runtime per node serves every assigned module's tools; a per-module tool container no longer exists | the runtime's tests; `docker ps` on a converged machine |
| A user-scoped unit is applied as the account | the host's tests |
| A node's difference from a module's default is visible as a setting with a source or a kept region | `mesh-controller.settings`; the host's write-into tests |
| The same manifests assign to a server and a workstation; the graphical ones are refused on the server by name | the resolver's tests and the live mesh |
## References
- [Research 018](../../01-RESEARCH/018-the-operators-machine-as-modules/00-overview.md)
- [To-be 29](29-a-node-has-operator-accounts.md) — the account and the home; this design is the
family its §2 names, beyond `~/.ssh`.
- [To-be 33](33-the-tools-the-mesh-answers.md), [to-be 34](34-the-console.md) — the tools and
the console, amended by ADR 0175.
- [To-be 05](05-the-node-host.md) — the host's vocabulary, widened by ADR 0177.
@@ -0,0 +1,193 @@
---
layer: to-be
status: in-progress
code: [mesh-tools, mesh-controller, mesh-host, mesh-catalog]
updated: 2026-10-02
decisions:
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
- 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
---
# 38. Building the operator's machine
**The work of [design 37](37-the-operators-machine.md), broken into packages small enough that each
ends at something a person can see run, in the order their dependencies allow.** Design 37 is the
authority on *what* is built; this document holds only the packages, their order, their sizes and
their proofs, and is wrong the moment it disagrees with 37 rather than the other way round. It is
the shape [design 28](28-building-the-bus.md) gave the bus work, applied here.
## How this is built, and where it is run
**On the live mesh, by the operator's decision.** Every package is written with unit tests and
committed on one branch per repository; its proof runs on the four machines, not in the lab.
[ADR 0149](../../02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md) already says the live mesh is
the test bed; the operator's words on 2026-10-02 were *skip the lab, it is not too bad if something
is broken*. The cost accepted: a package that breaks the runtime breaks every tool on a node until
the next push, and the controller's own verbs stay reachable through the controller seat whatever
happens to a node's runtime — which is the one thing that must hold, and does by construction
([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)).
Each package names what proves it. A package that cannot name its proof is divided until it can.
## What exists already, measured
Counted 2026-10-02 in the four repositories, non-test source. The point of the count is the same
as design 28's: nothing here is new ground; every package reshapes something standing.
| Piece | Today | Size | Becomes |
|---|---|---|---|
| the tool runtime | TypeScript: loads `MESH_TOOL_MODULES`, serves one module's tools and its claimed seats' verbs; `serve` is the console | ~1 700 lines over six files | loads every assigned module's bundle; `serve` is node tools |
| the host's `process` shape | Go: fetch a bundle by digest, unpack under the mesh's daemons directory, write the unit, run it | 343 lines | **unchanged** — the runtime is one such process |
| the host's `archive` shape | Go: fetch and unpack an artifact at a path | 185 lines | **unchanged** — a module's tools bundle is one such archive |
| the controller's bus principals | Go: one principal per module per node, grants from what it declares | 132 lines | gains one principal per node for the runtime |
| the controller's memberships | Go: one per assignment, the subjects a runtime serves | 143 lines | **unchanged** in shape; the runtime reads several |
| the controller's declaration composer | Go, one file | 2 053 lines | gains the runtime's process, the bundles' archives, two env words |
| the catalogue | 35 manifests build a per-module tool container on the runtime's base image | — | none do; the runtime is a module of its own |
**Two measurements decide the shape.** The host needs no change: a `process` and an `archive` are
what the runtime and a bundle are, and both are applied today. And the runtime already does
nine-tenths of the job — the loop over entrypoints, the seat verbs, the membership subscription —
for one module; the work is to let it do the same for a list.
## The order the work allows
```
WP1 the runtime serves many modules (mesh-tools) ──┐
WP2 the controller composes one runtime a node (mesh-controller) ──┤ independent, test-proven
│
WP3 the runtime is a module; the console is its serving mode (mesh-tools, mesh-catalog)
│
WP4 the first holder moves: the packet filter (mesh-catalog) ── the live proof
│
WP5 the shell, on a server (mesh-catalog) ── the first environment module live
WP6 the service manager, on a workstation (mesh-host #72, mesh-catalog)
│
WP7 the login manager, the display server, the window manager … ── one record per seat, after this document
WP8 settings for the theme knobs ── after issue 168 closes
```
WP1 and WP2 touch different repositories and meet only at the membership's shape, which neither
changes; they are built in parallel. WP3 needs both. WP4 is the first time anything on a machine
changes, and it is the proof of the whole. WP5 and WP6 are the first environment modules; the
packages after them are design 37 §4's candidates and are not broken down here, because each
begins with a decision record this document cannot anticipate.
## WP1 — The runtime serves many modules
*mesh-tools. About a day.*
**What changes.** `serve` takes a list of modules to serve, each with its entrypoints, rather than
one module and one credential. The runtime reads one membership per module from the subjects
[ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
derives for each, and serves each module's tools on that module's subjects and each held seat's
verbs on the seat's. The filter that drops a registration under any name but the one module goes;
what remains is the rule that a registration under a seat's name is served only where some module
the runtime serves claims that seat. A bundle that throws on import is named in the log and in
what `tools` answers, and the others serve. The runtime reads `MESH_OPERATOR_ACCOUNT` and
`MESH_OPERATOR_HOME` and hands them to every tool's environment.
**What does not change.** The SDK. The broker client. The MCP surface. A module's tool code.
**Proof.** The runtime's test against a real bus: three bundles, one of which throws on import;
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.
## WP2 — The controller composes one runtime per node
*mesh-controller. Two to three days; the largest package.*
**What changes**, in four pieces, each its own commit:
1. **A node principal.** Beside one principal per module per node, one per node of kind
`node-tools`: its serving grants are the union of every assigned module's tool subjects and every
held seat's verbs on that node, its invoking grant is `*`, and it consumes nothing. The
per-module memberships are composed as today; nothing else on the bus learns a new shape.
2. **Bundle delivery.** For every assigned module whose build produced a `bundle`, the node's
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
`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.
**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
memberships as before. The gate's test: the packet-filter manifest as it is today is refused once
the runtime is registered.
## WP3 — The runtime is a module, and the console is its serving mode
*mesh-tools and mesh-catalog. A day.*
**What changes.** mesh-tools gains a `bundle` artifact of itself beside its images, and its manifest
becomes the `node-tools` module: a package for the interpreter, the loopback listener the console
declared, `invokes: *`, and nothing else — the process is the controller's to compose (WP2). In the
catalogue, `mesh-console` is retired as a module and `node-tools` assigned where it was. The
runtime's `serve` keeps answering MCP on loopback; the person's end of it keeps the name *console*
([glossary](../../00-META/glossary.md)).
**Proof.** On every node: the console's container is gone, `node-tools` runs as a unit the host
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`.
## WP4 — The first holder moves: the packet filter
*mesh-catalog. Half a day. The live proof of ADR 0175.*
**What changes.** The nftables module drops its container, its `NET_ADMIN` and its runtime
artifact; its tools bundle stays and its claim stays. Its `remove` and `reload` escalate inside the
tool where they need root, which they have, since the runtime runs as the node's account.
**Proof.** `node-packet-filter.rules@<node>`, `reload` and `remove` answer from the runtime on all
four machines; `docker ps` shows no `mesh-nftables`; `status` is well. Then the fail2ban holder
proposed in an open change follows the same way when it lands.
## WP5 — The shell, on a server first
*mesh-catalog #224, already written. Half a day to assign and prove.*
**Order.** Assign `zsh` to one server; push; `login-shell.execute@<server> command="uptime"`
answers; the account's login shell reads zsh; its `~/.zshrc` carries the mesh's block with the
operator's lines around it. Then the other three nodes. The two things the manifest cannot say
— the `user` shape applying only where the seat is held, and a second shell module installed
beside the holder — are the first follow-up record after this document.
## WP6 — The service manager, on a workstation
*mesh-host #72 merged first; mesh-catalog #224. Half a day.*
**Order.** Merge the host's user-scope change and let it roll. Assign `systemd` everywhere;
`node-service-manager.units@<node> scope=user` answers on a workstation. Then the first user-scoped
unit the mesh sends: the window manager's reload watcher, declared `scope: user` by the window
manager module when WP7 writes it — until then, the host's change is proven by its tests and by
the verb answering.
## What is deliberately not here
- **The graphical stack's seats** (WP7). Each begins with a record naming its holders and verbs,
and the first graphical module asks the resolver a question this document cannot answer for it:
whether a held seat gates another's assignment.
- **Settings for the theme knobs** (WP8). Blocked on the settings record proposed in an open change
and on [issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md).
- **Reload without restart.** WP2 restarts the runtime on a bundle change; a reload that keeps the
other modules' tools up during one module's change is a refinement for after WP4 proves the
simple form.
- **Lingering.** A user-scoped unit answers only while the account's manager runs; declaring
lingering for the account is a field on the `user` shape, decided when a server first needs a
user unit.
## How this list is kept true
Each package's proof is run on the live mesh when the package is finished and its line here gains
the date and the commit, the way [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md)
carries *built and proven live*. A package whose proof fails is not reworded; the failure is
recorded under it and the package stays open. When WP6 is proven, design 37's status moves to
`implemented` for what it covers and this document's to the same.
@@ -0,0 +1,170 @@
---
layer: to-be
status: designed
code: []
updated: 2026-10-02
decisions:
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 02-DECISIONS/0024-model-access-is-a-provision.md
- 02-DECISIONS/0050-model-access-is-vendor-agnostic.md
- 02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md
- 02-DECISIONS/0113-the-vault-makes-every-secret.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
- 02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md
---
# 39 — The Anthropic licence manager
**One module knows every Anthropic licence the mesh has, keeps each alive, decides which consumer gets
which, and hands every node's agent its token over the bus.**
[ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
decides it; this is the shape. It is the successor of the predecessor's manager module, built from what
that module learned the hard way, and the counterpart of [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md),
which is the consumer on every node.
## 1. What it is
A module, `claude-licence-manager`, holding the mesh-scoped seat **`anthropic-licence-manager`**. One
holder, on the node the operator assigns it to — the control node is the natural one, and nothing in the
definition says so. It requires a database for its own store and the bus; it claims the seat; it serves
the seat's verbs. It has no port, no route, no file under anyone's home.
Its store holds four things:
| table | holds |
|---|---|
| **licences** | name, kind (`subscription` or `api-key`), the account's identity (id, address, organisation) once adopted, the grant encrypted at rest, when the access token expires, when the refresh token expires, consecutive failures, the refresh lease, when a person was last notified |
| **bindings** | one row per consumer: kind (`node-agent`, `node-session`, `worker`), its key (the node, or the node and the worker), the licence, or *inherit* |
| **usage** | the vendor's readings per licence per period, raw beside normalised ([ADR 0054](../../02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md)) |
| **audit** | every switch, adoption, refusal and drift, with who asked |
**The grants are encrypted with a key the vault made for the manager** — its one `secret` requirement.
The vault keeps that key; the manager keeps the grants. That is ADR 0050's carve-out, one module, one
node, the long-lived grants only.
## 2. The licences it manages today
Two subscription accounts and one API key. They differ in kind and the manager treats them so:
| kind | what the grant is | refresh | what a node is handed | how the agent uses it |
|---|---|---|---|---|
| `subscription` | an OAuth grant: an access token that lives hours and a refresh token that lives weeks | the manager rotates it, alone | the access token only | written into the agent's credentials file by the agent module, as the operator |
| `api-key` | a key the operator obtained from the vendor | none; a new key is a new adoption | the key | served to the agent through its key-helper setting; nothing is written under the home |
## 3. Keeping a grant alive
Carried from the predecessor, where each rule was earned by an incident:
- **One rotation source.** Only this module calls the vendor's token endpoint. An OAuth refresh is
presumed to rotate the refresh token, so a second refresher presenting the old one would kill the
grant; whether that presumption holds is to be measured in the lab, and the design is safe either way.
- **A lease per licence**, taken in the store before the row is read. A duplicate run sees the token its
predecessor just wrote, finds hours of life on it, and does nothing.
- **An expiry floor and a cadence.** Within an hour of expiry a refresh must happen; otherwise a grant is
rotated once it is older than a declared setting, so a node that misses one rotation still holds hours
of life and a broken refresh surfaces in minutes rather than the next morning.
- **Failure is counted and escalated once.** Consecutive failures are recorded; past a threshold a
notification is emitted, and at most once a day while it stays broken — the predecessor sent one alarm
411 times in 35 hours and the incident went unnoticed inside its own alarm.
- **A refresh token's own expiry is warned about three days ahead**, because the only remedy is a person
logging in again.
- **The vendor's reason is logged**, never only the status code: a malformed request and a revoked grant
both answer 400, and the predecessor built three concurrency fixes for a bug that was a wrong client id.
## 4. Handing a token to a node
Every node that runs the agent module registers that module's public key with the seat when it first
runs. From then on:
- **On rotation**, the manager calls `claude-code.apply@<node>` on every node bound to the rotated
licence, with the new token sealed to that node's module key. The module answers *applied*, or
*refused* and why, and the manager records it.
- **On a switch**, the same call with the other licence's token, and the binding is the authority: the
module applies a bind without comparing expiries, because across two licences the numbers are
unrelated.
- **On a pull** — the module starting, or finding its token near expiry — the module calls the seat's
`current` verb for its binding and is answered sealed the same way.
- **Never as an event.** What the manager emits names the licence and the outcome and carries no token.
A node whose module has not registered a key cannot be handed a token, and the manager says so by name
rather than falling silent. A node whose module refuses — a wrong identity, a stale grant within one
lineage — is recorded as drift and reported.
## 5. Who gets which licence
Three consumer kinds, the predecessor's touchpoints with their fallbacks:
| consumer | bound by | falls back to | if the bound licence cannot be served |
|---|---|---|---|
| **the node's interactive agent** | the node | nothing: an unbound node has no licence and the agent says so | keeps the last token, which expires within hours; a notification is emitted |
| **the mesh's session on a node** | the node, for that session | the node's agent licence | refused |
| **a worker** | the worker | the node's session licence, then the node's | refused: a worker never borrows a person's account |
**One agent directory per machine, shared by every interactive session**, so a node's binding is the
licence of all its sessions at once. A worker is a consumer of its own because it runs from a home of its
own, with its own agent directory and credentials file, which the agent module on that node writes for
it as it writes the operator's — the predecessor ran its agents exactly so.
**Binding is a person's act through the seat's verbs**, listed by the console: `bind`, `switch`,
`release`. **Exhaustion is observed, not acted on**: usage is read every few minutes, a crossing of a
declared threshold in the five-hour window is notified once per crossing, and moving a consumer is the
operator's call. Switching remains a reaction, not a declaration
([ADR 0024](../../02-DECISIONS/0024-model-access-is-a-provision.md)), and an automated policy — move to the
least-used licence, stay off a dying one — is designed later if wanted, on the readings this module
already keeps.
## 6. Adopting a grant
A licence enters the mesh one of two ways, and the token never passes through a prompt, a terminal or an
argument:
- **From a node's login.** A person logs in on a node, as they always have. The agent module there reads
the account's identity from the agent's own state file, and offers the full grant to the seat sealed
to the manager's key. The manager adopts it into the licence the node is bound to **only if the
identity matches** that licence's recorded account; a licence not yet identified is identified by its
first adoption; a mismatch is refused and notified, because the predecessor once filed one account's
grant into another's row this way.
- **An API key** is delivered to the manager by the operator through the seat's `adopt` verb from a file
on the manager's node, never as an argument.
## 7. What it emits and serves
**Events**, no secret in any: `licence.rotated`, `licence.switched`, `licence.adopted`,
`licence.failing`, `licence.refused`, `usage.read` — the audit logger records them all.
**The seat's verbs**, the contract every future holder must serve: `licences` (each with kind,
identity, expiry, failures, who is bound), `bindings`, `bind`, `switch`, `release`, `refresh` (now, one
or all), `usage` (current and history), `adopt`, `register` (a node's module key), `current` (a
consumer's token, sealed, asked by the consumer's module).
## 8. Settings
The refresh cadence; the usage threshold; the notification cooldown. Each declared with a default, so
one definition serves and one mesh may differ.
## How it is checked
| Check | Defends |
|---|---|
| two refresh runs started together rotate one grant once; the second does nothing and says so | ADR 0183, one rotation source |
| every event the manager emits is free of any token; the hand-over opens only with the receiving module's key | ADR 0183, to-be 32 §10 |
| a worker bound to a dead licence is refused, never answered with another licence's token | ADR 0183, the fallbacks |
| a grant offered with a mismatching identity is refused and one notification emitted | ADR 0183, attribution |
| a failing licence notifies once, and once a day after, not once per tick | §3 |
| the console lists the seat's verbs and `switch` changes a workstation's token end to end | ADR 0132, the exit of the build |
## What this does not settle
- An automated switch on exhaustion (§5).
- Whether an OAuth refresh token is single-use; the lab measures it, and §3 holds either way.
- How the mesh's own session and a worker read their token on a node once those exist
([to-be 15](15-the-agent-session.md), [ADR 0003](../../02-DECISIONS/0003-agents-are-persistent-employees.md)):
the agent module on that node is their local source, and the reading is theirs to design.
## References
- [ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the decision
- [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md) — the consumer on every node
- [14 — Model access](14-model-access.md) — the vendor-blind provision this sits beside
- the predecessor's `claude-licences` module: the lease, the floor, the cadence, the cooldown, the identity guard — read 2026-10-02
+3 -1
View File
@@ -38,9 +38,11 @@ document is written and this one's status becomes `implemented`.
| [`26-the-seats.md`](26-the-seats.md) | **Proposed.** What a mesh can have one of, who fills each, and a seat's holder answering for the provision it delivers — including the `git` seat a build's source can live on | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md), [ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md) |
| [`27-a-module-requires-the-mesh-resolves.md`](27-a-module-requires-the-mesh-resolves.md) | **Proposed.** One concept for everything a module needs: a requirement with a contract, answered by one of four kinds of provider, resolved at assignment or refused. Retires settings, placeholders, facts and paths in definitions | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md), [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)) |
| [`28-building-the-bus.md`](28-building-the-bus.md) | **Proposed.** The five steps of the bus work in the order their dependencies allow, each ending at a bed — with the surface measured, so no step's size is a guess | [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md), [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md), [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) |
| [`29-a-node-has-operator-accounts.md`](29-a-node-has-operator-accounts.md) | **Proposed.** The mesh models machines but not the humans on them: a node gains operator accounts, and a resource may live under a home owned by its account — what would own ~/.ssh, dotfiles and ~/.config when HAL retires | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md) |
| [`29-a-node-has-operator-accounts.md`](29-a-node-has-operator-accounts.md) | **In progress.** A node has an operator account and a resource may live under its home — built in the controller; the ssh-client module, the SSH CA, the `~/.ssh` boundary and user-scoped services are not. The account fact still wants its decision record | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md) |
| [`32-what-a-module-declares.md`](32-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md), superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) |
| [`37-the-operators-machine.md`](37-the-operators-machine.md) | **In progress.** Every configurable thing on a node is a module, the home included; one default per module varied by settings or kept regions; roles a machine has once as seats with tool contracts; one tool runtime per node on the host side | [ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md), [0174](../../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md), [0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0176](../../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md), [0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md) |
| [`38-building-the-operators-machine.md`](38-building-the-operators-machine.md) | **In progress.** The work of design 37 as packages: the runtime serves many modules, the controller composes one per node, the console becomes its serving mode, the packet filter moves first, then the shell and the service manager — tested on the live mesh by the operator's decision | [ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [0149](../../02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md) |
## Not yet written
@@ -1,8 +1,8 @@
---
status: located
status: resolved
opened: 2026-09-22
located-in: [mesh-controller internal/overlay, mesh-host internal/apply]
fixed-by:
fixed-by: ADR 0102 (mesh-controller internal/overlay: the runtime file written into, reloaded), issue 128 (the hosts file as a region)
amended-design: 03-DESIGN/01-to-be/05-the-node-host.md
---
@@ -56,3 +56,12 @@ and nothing checks for it today.
- Should an adopted node that cannot trust the registry be refused a module that needs to pull?
Or should the refusal come earlier, when the node joins?
## Resolved, 2026-10-02
The runtime's file is written into and the runtime reloaded, never restarted
([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md), the
diagnosis above); the hosts file is a marked region the mesh owns alone
([issue 128](../128-the-hosts-file-is-written-whole/00-report.md)). Neither whole file remains. Read
into [ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), rule 5,
which names the one whole machine-wide file the mesh still writes — its own filter at the
distribution's path — as a difference a take shows, not a fault.
@@ -1,10 +1,10 @@
---
status: located
status: resolved
opened: 2026-09-28
located-in:
- mesh-controller internal/catalogue/filtering.go
- mesh-host internal/apply
fixed-by:
fixed-by: ADR 0140 — mesh-controller (the filter around outward links; no network ranges anywhere)
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
---
@@ -86,3 +86,11 @@ supersedes both 0137 and the first attempt at answering this.
runtime, or left as the one constant?
- Should the preview say which of a machine's networks are the mesh's and which are not, so a range
that exists to protect a leftover is visible as such?
## Resolved, 2026-10-02
By [ADR 0140](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md), built and
live since 2026-09-29: the forward chain constrains what arrives on the machine's outward links and
says nothing about networks, so there is no list to derive and nothing for a preview to tell apart.
Read into [ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md),
rule 5, which closes it.
@@ -1,10 +1,10 @@
---
status: located
status: resolved
opened: 2026-09-29
located-in:
- mesh-host internal/apply/opening.go (retireFirewall)
- mesh-host internal/apply/apply.go (the condition it is called under)
fixed-by:
fixed-by: mesh-host 67 (retire on every converged apply; found-inactive apart from disabled-by-mesh; a skipped step said), mesh-controller 211 (the found firewall's state on node show)
amended-design:
---
@@ -100,3 +100,21 @@ harmless, but the mesh's belief about which firewall is in force has been wrong
so". Should it?
- Why do the host's own detail lines not reach the journal? Everything it decided during the flip is
unrecoverable, which is why this account has candidates instead of a cause.
## Decided, 2026-10-02
[ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), rule 1:
convergence is a state the host keeps — the found firewall active again is retired again and said, a
reconcile that finds it inactive records *found so* and never *done by the mesh*, and a step skipped
after a failed apply is said. Built in mesh-host on `feat/one-thing-filters-a-converged-machine`; the
record of both machines of this mesh is corrected by the first report under it.
## Resolved, 2026-10-02
mesh-host 67 and mesh-controller 211, live on every machine at 10:10Z. The step now runs on every
converged apply and says what it did; a found firewall enabled again is retired again. The record's
one inherited lie stands as history: on the control node the machine's own record already said the
mesh had disabled the firewall, and the host trusts its record, so `node show` says "retired by the
mesh" there. From this build on, a reconcile that finds the firewall inactive records *found inactive*
and never the other thing. Whether the flip's step took on 2026-09-29 is not recoverable and is not
owed by this record any more.
@@ -1,10 +1,10 @@
---
status: located
status: resolved
opened: 2026-09-29
located-in:
- mesh-host internal/apply/opening.go
- mesh-controller cmd/mesh-controller (the converge preview)
fixed-by:
fixed-by: mesh-host 67 (every refusing table and legacy chain classified with an owner; the runtime's user chain is other), mesh-controller 211 (kept, shown on node show, named by status, previewed with fates)
amended-design:
---
@@ -82,3 +82,24 @@ everything reached from within.
- Is the bus and the registry being reachable from anywhere still what the mesh wants on a machine that
faces the internet? The design says yes, for enrolment. It deserves asking on its own rather than
being answered by a leftover.
## Decided, 2026-10-02
[ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), rules 2 and
3: the host reports every table and legacy chain that refuses, with an owner, and the runtime's user
chain's refusals as *other*; `node show`, `status` and the converge preview say it. Built on
`feat/one-thing-filters-a-converged-machine` in mesh-host and mesh-controller. On 2026-10-02 the home
server still carries the predecessor's chain in its legacy filter; the record's live row is reading it
there.
## Resolved, 2026-10-02
mesh-host 67 and mesh-controller 211, live at 10:10Z. The live row of
[ADR 0168](../../02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md) was read the
same hour: the home server's record names the predecessor's chain in the legacy filter's user chain
as *other*, with what it refuses, beside two chains a retired front end left in the IPv6 legacy filter;
the control node's record names the same two leftovers; the laptop and the workstation read *the mesh
alone*. `status` names both machines and is not well until the operator removes what the mesh did not
write. The allowance the predecessor's chain carried is
[issue 145](../145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)'s,
and that record is not closed by this one.
@@ -0,0 +1,43 @@
---
status: resolved
opened: 2026-10-02
located-in: [mesh-host internal/outward (Links reported only the links carrying a default route)]
fixed-by: mesh-host PR 66 (a link backed by a physical device is named outward, up or down), live 2026-10-02
amended-design: []
---
# 197 — A physical link that is down is not filtered when it comes up
## What was observed
A sweep of every machine's filter on 2026-10-02. A laptop-class machine connected by its radio has a
wired port that was unplugged. Its filter guarded the radio and the tunnel, and accepted everything
arriving on any other link:
```
iifname != { "mesh0", "<radio>" } accept
```
The wired port was not in the list. Plugged in, everything arriving on it would have been accepted,
every port of the machine open to whatever network the cable reached. That would last until the
machine reported again and was pushed a new filter.
## Why it matters
**The filter's one rule about links fails open.** [ADR 0140](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md)
has the filter constrain what arrives from outside, and has the machine say which links face outside.
Everything not named is treated as the machine's own, its containers and bridges. So a link the machine
fails to name is not filtered at all. The host named only the links carrying a default route at the
moment it reported. A cable plugged in later is the ordinary case for a laptop. A second wired network
that never carries the default route, such as a direct link to a storage box, is never named at all.
## Open questions
- A virtual link that faces outside (a VPN client's interface, a USB tether that appears as a virtual
device) has no physical device behind it. It is named only while it carries the default route. Is
that enough?
## Resolved (2026-10-02)
Live on the affected machine after the host was delivered and one more push: its filter now guards the
radio, the tunnel and the unplugged wired port, before anything is plugged into it.
@@ -0,0 +1,14 @@
# Diagnosis
*2026-10-02.*
**Located in `mesh-host` `internal/outward`.** `Links` read the kernel's routing tables and returned the
interfaces carrying a default route. An unplugged port carries none, so it was never reported, and the
controller rendered the filter around the links it was given.
**The fix.** A link faces outside if it carries a default route **or** has a physical device behind it.
The kernel lists every interface under `/sys/class/net`, with a `device` entry for one backed by
hardware. A bridge, a veth, the tunnel and the loopback have none, so they stay the machine's own. The
wired port is now reported up or down, and the filter guards it before anything is plugged in. Tested
with a radio carrying the default route and an unplugged wired port beside a bridge, a veth, the docker
bridge, the tunnel and the loopback: the two physical links are reported, nothing else.
@@ -0,0 +1,71 @@
---
status: resolved
opened: 2026-10-02
located-in: [mesh-catalog modules/dnsmasq (listens on loopback and the machine's mesh address only), the home-server's DNS (a predecessor's dnsmasq configuration the mesh did not own), the home network's DHCP (hands out the home-server as every device's DNS)]
fixed-by: mesh-catalog PR 214 (dnsmasq listens on addresses from a setting; docker's file takes no settings), mesh-controller PR 210 (the settings verb), mesh-catalog PR 215 (unifi network DNS tools), live 2026-10-02
amended-design: []
---
# 198 — The home network's DNS server ran outside the mesh, and the mesh's filter closed it
## What was observed
Every phone on the home Wi-Fi had no internet, while a laptop on the same Wi-Fi did. The router's
DHCP hands every device the home-server's LAN address as its DNS server. The home-server's DNS daemon
was listening on that address, and every query to it timed out. The router itself answered the same
query at once. The laptop worked because it resolves through its own local resolver, not through the
server DHCP names.
## Why it happened
The DNS daemon on the home-server was not the mesh's. It ran under a configuration file a predecessor
generated, listening on loopback, the mesh address and the LAN address. The mesh's `dnsmasq` module was
assigned to the other three machines and not to this one, so no module on the home-server declared
port 53. Its filter opens only what a module declares, so DNS from the LAN was dropped. It started when
the home-server applied the filter this morning, after nine hours of applying nothing
([issue 194](../194-the-hosts-own-former-archive-stops-every-apply/00-report.md)).
Nothing said so. The daemon reported running, the filter applied cleanly, and the mesh had no record
that the home network depended on a service it did not know.
## Why it matters
**A service the mesh does not know is closed by the mesh's filter, by design, and nothing asks whether
something depends on it.** That is the right default for an unknown port. It is the wrong outcome for
the one service a whole network was told to use. The gap is that a machine can run something
important outside the mesh with nothing to show it.
**The mesh's `dnsmasq` could not have served the LAN either.** It listened on loopback and the mesh
address only. The reach of its DNS endpoints opens the filter, but the daemon would not have been
listening on the LAN address anyway.
## Open questions
- Should a machine report the listening services the mesh does not own, the way it reports the links
that face outside? This one would have been visible before the filter closed it.
- The LAN address the home-server answers on is now a setting, beside the reach that opens the filter.
Two statements that must agree. Should reach `public` on a DNS endpoint imply listening beyond the
mesh?
## Resolved (2026-10-02)
The home network was pointed at the gateway for DNS while the fix was built, which got the phones back
within minutes. Then:
- the mesh's `dnsmasq` takes the addresses it listens on beside the machine's from a setting, with
loopback as the mesh-wide default, so no other machine changed;
- the home-server's layer adds its LAN address, and its DNS endpoints' reach is `public`. The router
forwards no DNS, so that means the LAN;
- the module and its sibling `resolv-conf` were assigned to the home-server, replacing the
predecessor's daemon and configuration, which were kept aside;
- the home network was pointed back at the home-server, through a new `unifi` tool.
Checked live: from another machine on the LAN, public names and mesh names both resolve through the
home-server's LAN address, and the mesh and the machine itself resolve as before.
**One fault found on the way, and caught before it reached any machine.** A module's settings are
merged into every mergeable file the module owns. The first attempt therefore put the new setting into
docker's `daemon.json` as well as into dnsmasq's config, and dockerd refuses keys it does not know. The
plan showed it before any push. The change was reverted and redone with docker's file declared to take
no settings. The general fault, a module's settings reaching files they were not meant for, is still
there for any module with more than one file.
@@ -0,0 +1,35 @@
---
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.
@@ -0,0 +1,36 @@
---
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?
@@ -0,0 +1,88 @@
---
status: open
opened: 2026-10-02
located-in:
- mesh-controller
fixed-by: 02-DECISIONS/0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md
amended-design:
---
# 201 — A push recreated the controller at a digest older than the seat row its successor had written
## What was observed
2026-10-02, two merges a minute apart on the control node: one to the controller, adding a verb to the
controller seat's row; one to the host, adding a container field. Each made a plan. The controller's plan
built and rolled the new controller, which started, widened its seat row with the new verb, and ran. The
host's plan then pushed the control node with the controller digest it had recorded when it was made —
the previous build — and recreated the controller container on it. The older binary read the row, found
a verb it could not run, and refused to start:
```
mesh-controller: the mesh-controller seat's row declares "command", which this control plane
cannot run: "command" is not a verb the mesh-controller seat serves
```
A crash loop followed for ten minutes: nothing answered on the bus, and no build was dispatched, since
the controller is what fills the builder's queue. Recovery was the mesh's own binary run once from the
newer image, outside the service, to push the control node again; the push sent the newer digest and
the controller came up.
## Why it matters beyond this instance
The row is the store's and the binary follows it ([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md));
a start-up check that refuses a row the binary cannot serve is right, and was built after the outage of
2026-09-27 for exactly this reason. What is wrong is a plan sending a controller older than the one that
wrote the row. A plan is made at a moment and sends what it recorded ([ADR 0162](../../02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md));
for every other module an older digest is a brief regression a later push corrects. For the controller
it is the mesh losing its voice, and the correction needs a hand, because the thing that would correct
it is the thing that is down. Two plans that overlap will happen again whenever two people merge within
a minute.
## What a fix would have to do
Either of two, and the first is the smaller:
- A push never sends a controller digest older than the one the running controller is — the controller
knows its own digest and refuses to downgrade itself through a plan, saying so in the plan's words.
- Or the start-up check tolerates a row wider than the binary while a roll-out is in flight, and serves
what it can. Weaker: it makes the row and the binary disagree on purpose, which is what the check
exists to refuse.
Until one is built: do not merge a controller change while another plan is rolling, and after merging
one, wait for `node show` on the control node to report the new controller before merging anything else.
## References
- [ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0162](../../02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
- mesh-controller `cmd/mesh-controller/seatverbs.go` (`seatToolHandlers`, the start-up check), `cmd/mesh-controller/push.go`
## Half of it is closed, 2026-10-02
[ADR 0185](../../02-DECISIONS/0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md) takes
the outage out of it: a control plane behind its seat's row now serves every verb it can run, says
which it cannot, and answers the reason when one of those is called. The same race today would cost
the verbs the newer build added, for as long as the older binary is in place, and the ordinary
"this machine is behind" machinery would put the newer one back without a hand.
**The race itself is still open**, and this report stays open for it. What was established while
closing the other half, so the next reader does not redo it:
- Composing and sending are serialised per machine by a session advisory lock in the store, so two
control planes cannot compose one machine's declaration at the same time. The stale content did
not come from two concurrent composes.
- A container's image is resolved into the module's manifest when it is *built*, and a push composes
from the catalogue as it is at that moment, under the hold. So a compose that ran after the build
was taken in could not have named the older image.
- The declaration's sequence orders arrival and nothing else (the numbering of
[issue 107](../107-a-declaration-carries-no-order/00-report.md)); it cannot tell a later send
carrying earlier content from a later send carrying later content. The host refuses a declaration
numbered below the last it applied, and both of these were above it.
- The machine's own journal shows the two applies ten seconds apart and which replaced what; it does
not record which image each declaration named, which is the one fact that would settle it. A host
that recorded the digest it was told, per apply, would have answered this in a minute.
So the trigger is not yet pinned, and guessing at the push path is the most expensive place in the
mesh to guess. The fix the report first suggested — a push never sending a control plane a digest
older than the one that machine reports running — closes the class without needing the trigger, and
is now a correctness nicety rather than the difference between a working mesh and a dead one.