Compare commits

..
Author SHA1 Message Date
mesh-admin 438162b5a5 Merge pull request 'Issues 225, 226 and 227 resolved with their live proofs; 232 opened and resolved' (#357) from issues/228-photos-authenticates-against-admin into main 2026-10-04 10:37:53 +00:00
jschoubben ab1bd5598e Issues 225, 226, 227 resolved with their live proofs; 232 opened and resolved
225: a grant secret is composed with the account that reads it — the node's
account for a bundle, the declared secrets-owner for a container. Zero EACCES
since 12:30:10 where there had been 4330, both users created, mongodb logging
Authentication succeeded for each. The harness also stops calling a permanent
refusal a race, which is the half that cost three hours.

226: normalising moved to the records, where the provenance is known, and a
reference the sweep will not address is skipped rather than ending the sweep.
The first build after the roll-out collected 200 and said 1126 remain — the
backlog falls with every build instead of standing at 1681 for ever.

227: the three photo modules publish the endpoint they declare, and a
catalogue-wide test makes it a rule: a container publishes only a port its
module declares, or the mesh has nothing to assign and the number escapes.

232 came out from under 225: photos asked for a database its user does not
live in, invisible while no user existed at all.
2026-10-04 12:37:31 +02:00
jschoubben 3f3fb99219 Issue 232: a consumer authenticates against a database its user does not live in
Found fixing 225: with the secrets readable the provisioner created both
users at once, and photos still could not connect because it asks for admin
while its user lives in its own database. The password was never wrong — the
grant secret and the consumer's environment hash identically.

One fault wore the other's clothes: while no user existed anywhere, the
error was a complete account of 225. Worth keeping as a habit — fix the
first and look again.
2026-10-04 12:33:57 +02:00
mesh-admin 3afe619531 Merge pull request 'ADR 0208: the graphical session is one module per piece, on the mesh's seats' (#356) from decision/0208-the-graphical-session-is-modules-on-seats into main 2026-10-04 10:29:20 +00:00
jochen 502cf4839b ADR 0208: the graphical session is one module per piece, on the mesh's seats
Eleven node seats; a display as a provision with the machine's reach; other
modules' lines through the tool's own drop-in directory or ADR 0204's slots,
now also for xinitrc and xresources; the display server's module writes the
session's start.
2026-10-04 12:29:13 +02:00
mesh-admin 0ba68c154e Merge pull request 'Issue 162 resolved: an archive can be undeclared (mesh-host#90)' (#355) from issues/162-resolved into main 2026-10-04 10:25:44 +00:00
jochen 460793af1c Issue 162 resolved: an archive can be undeclared (mesh-host#90) 2026-10-04 12:23:06 +02:00
mesh-admin 25de331e9b Merge pull request 'ADR 0207: a module depends on the node seats that apply its resources' (#354) from decision/0207-a-module-depends-on-the-seats-that-apply-its-resources into main 2026-10-04 10:21:46 +00:00
jochen ed5ddcdef6 ADR 0207 extends ADR 0177 (accepted); 0166 stays a reference 2026-10-04 12:21:40 +02:00
jochen e4f80cc3ce ADR 0207: a module depends on the node seats that apply its resources
A service needs node-service-manager held on its node, a package
node-package-manager, a container node-container-runtime: derived from the
resources, never stated; refused at assign, reported at composition until the
three holders are on every node. Glossary: depends on a seat; nothing claims a
package.
2026-10-04 12:21:24 +02:00
mesh-admin d2689c0f86 Merge pull request 'Issues 229 and 230: a rollout cannot be followed through the mesh's tools; a host hand-over loses its report and a plan waits for ever' (#352) from issues/229-a-rollout-cannot-be-followed-through-the-meshs-tools into main 2026-10-04 10:09:34 +00:00
mesh-admin 719aa6bd62 Merge pull request 'Research 026 and 027, issue 231, to-be 42: the graphical session, the system layer, and the order they are built in' (#353) from research/026-027-the-graphical-session-and-the-system-layer into main 2026-10-04 10:08:33 +00:00
jochen ca13f59c88 To-be 42: the machines' modules, in order — every machine's, then the workstations', then one model's 2026-10-04 12:08:26 +02:00
jochen f8a0402485 Merge remote-tracking branch 'origin/main' into research/026-027-the-graphical-session-and-the-system-layer 2026-10-04 12:08:17 +02:00
jochen 9016d88d54 Research 026/027: improve while adopting, the fonts chosen, and a catalogue of the tools each module serves 2026-10-04 12:06:08 +02:00
jochen 6e5dfd2ab8 Research 027/03: the laptop's power management; 026/04: fonts 2026-10-04 12:03:29 +02:00
mesh-admin 872f20d51f Merge pull request 'To-be 40: building the operator's agent and its licence manager as work packages' (#297) from feat/claude-code-work-packages into main 2026-10-04 10:01:15 +00:00
jochen 550453c5db Research 026/04: the clipboard 2026-10-04 12:00:20 +02:00
jochen b7aebedc2d Research 026/04: the screensaver, monitor layouts and menus 2026-10-04 12:00:01 +02:00
jochen 27b2d30441 Research 027/03: ~/.ssh as one module's, scripts on every machine, the keyring, mail as events 2026-10-04 11:59:23 +02:00
jochen 82fa5f79ea ADR 0206: a node reports the grant it holds; the manager adopts a licence by refreshing it
The operator's flow: clients publish what their credentials file holds, the
manager takes in a licence it does not own and rotates it from then on. The
token itself cannot be published (design 32 §10, ADR 0201), so a node reports
fingerprints and identity as state and hands the grant over only when the
manager asks; adopting is refreshing, newest login first; bindings with a
generation replace the rotated/switched events. Designs 36 and 39 and to-be 40
amended; a pointer note on ADR 0183.
2026-10-04 11:58:46 +02:00
jochen f6668d76d6 There is no home-scoped module: ADR 0181 and 0182 say so as progressive insights; design 36 and to-be 40: the module declares the two directories it owns
ADR 0173 §2: a module is what it declares, and there are no kinds of module. The two records called
a resource under a home and a module placing one home-scoped; the wording is corrected in place,
marked and dated, the decisions unchanged. Design 36 and to-be 40 now say the module declares
/etc/claude-code and ~/.claude as directories, so the ownership check sees both, and declares no file
under either (mesh-catalog #244).
2026-10-04 11:56:32 +02:00
jochen f5d54db7aa Plan and designs after ADR 0193, 0195 and 0198: bundles are launched and the runtime is their bus; the manager's daemon is a long-running bundle; the console's five tools
The dated note on ADR 0183 now rests on ADR 0193 and 0198 rather than on a bundle having no way to
call: the manager starts every exchange by the operator's direction, through mesh/ask. To-be 40's
WP4 no longer waits on a record — ADR 0198 is it — and the live proofs count the console's five
tools (ADR 0195).
2026-10-04 11:56:32 +02:00
jochen bcf010886d Design 36 §4: the console is registered in the exclusive managed tool-server file, because the managed-settings key refuses a non-https URL 2026-10-04 11:56:32 +02:00
jochen 2eba399e1e To-be 40 revised for the tools refactor; the manager starts every exchange (ADR 0183 dated note, designs 36 and 39)
Design 38's WP1-WP4b ran: the node's tool runtime is live on all four machines as the operator
account, tools are bundles given only their declared words, and a bundle has no bus credential.
So the wait on design 38 WP3 is over, the agent module calls nothing and the manager starts every
exchange (key, hand-over, waiting login, reconcile), and the manager's daemon now waits on WP4c's
record instead. Accounts are stated on all four, sudo -n works for each, the agent is installed on
all four; the plan's WP0 shrinks and WP2 gets a configuration-only live proof before any licence.
2026-10-04 11:56:32 +02:00
jochen d227ed12d2 To-be 40: building the operator's agent and its licence manager as work packages
Designs 36 and 39 say what is built; this says in which order and what proves each step, in the
shape to-be 38 gave the operator's machine. Seven packages: the operator states the facts (accounts,
roles, licences); the console provides its endpoint; the licence manager and the agent module are
built and unit-tested in parallel; the manager goes live on the control node; the agent on one
workstation, with the switch and the predecessor's files removed as the proof of the whole; then the
rest of the nodes and the retirement of the two catalogue modules built on the old placement. The
live proofs wait for to-be 38's WP3, because both modules' tools run in the node's tool runtime
(ADR 0175) and a per-module tool container would rebuild what that record retires.
2026-10-04 11:56:32 +02:00
jochen 8712d666bf Research 026/03: what the predecessor taught; issue 231: a misspelled placeholder is written out as text 2026-10-04 11:49:19 +02:00
jochen 61e70b9395 Research 027: the operator's choices, the hosts file, mounts, and two DHCP clients on one interface 2026-10-04 11:21:35 +02:00
jochen de032e704c Research 026 (the graphical session) and 027 (the system layer)
Evidence from both workstations and all four machines, read-only, and the
questions each must answer: seats and gating for the display stack, who starts
the session with which environment, contributions beyond shells, sway as a
sibling session; the container runtime with docker-compose on workstations
only, software outside the official repositories, secrets in the account's
environment, and three security findings.
2026-10-04 11:17:34 +02:00
jochen 0c2eae07c5 Issue 229: the stale tool list is a connection opened before discovery; the fix is list-changed 2026-10-04 11:01:50 +02:00
jochen f23a71e0d7 Issue 230: a report is also lost when the apply restarts the bus 2026-10-04 11:00:27 +02:00
jochen 9c13c89fa3 Issue 229: new tools never reach an agent's connection, and the console's generic tools are not on it 2026-10-04 11:00:13 +02:00
jochen 2db0ea268d Issue 230: a host that hands over loses its report, and a plan waits for it for ever without saying so 2026-10-04 10:54:35 +02:00
jochen 8d83d94659 Issue 229: a rollout cannot be followed through the mesh's tools 2026-10-04 10:51:45 +02:00
mesh-admin a8ffc2b94b Merge pull request 'Research 025 graduated: ADRs 0203–0205, issue 228, to-be 41 (the shell and the account's environment)' (#351) from feat/the-shell-and-its-environment into main 2026-10-04 08:49:36 +00:00
jochen 1dcbdae1c4 Issue 225 → 228: the number was taken on main while this branch was open 2026-10-04 10:30:46 +02:00
jochen c3ec48f85c To-be 41 WP1: directories made inside a home belong to its account 2026-10-04 10:30:23 +02:00
jochen 72eda923c7 ADR 0205: the vendored theme's measured size 2026-10-04 10:30:23 +02:00
jochen c4fedcdbe3 To-be 41 WP1: a shell that refuses logins need not be listed; giving back is never fatal 2026-10-04 10:30:23 +02:00
jochen 0bf70ee8b4 Graduate research 025: the environment and the shell's contributions
ADR 0203: the account's environment is one module's (seat node-environment);
every module contributes variables and PATH entries, rendered by the
controller as a POSIX file and as environment.d.
ADR 0204: shell code is contributed to the login shell in named slots, and
login-shell becomes the mesh's node-login-shell.
ADR 0205: software the distribution does not package ships as a pinned
archive of the module.
Issue 225: undeclaring a user stops a node applying; the shell is never
given back or checked.
To-be 41 carries the work packages; to-be 38 WP5 points to it.
2026-10-04 10:30:23 +02:00
jochen 947b85af5e Research 025: an environment module holds the account's environment
The operator's proposal: one module, holding a mesh seat node-environment, is
the only writer of the account's environment. It renders contributed variables
and PATH entries as a POSIX file shells source and as environment.d for the
graphical session. The shell's contribution shrinks to shell code; the shell
render-then-service-manager-again positions become options weighed against it.
2026-10-04 10:30:18 +02:00
jochen ac6c306df3 Research 025: how a module plugs into the operator's shell
Opened after the zsh module's rollout (to-be 38 WP5) was stopped: every machine
carries the same predecessor-written startup file, the module's block would
duplicate it and drop lines, nothing installs the prompt, and execute never
reads .zshrc. Weighs how modules contribute environment and shell code, where
the operator's own lines go, ordering, and what a contribution is addressed to.
2026-10-04 10:30:18 +02:00
mesh-admin 96df3ccc88 Merge pull request 'ADR 0201 → 0202, and three issues from the night group 8 shipped' (#350) from issues/the-night-of-group-8 into main 2026-10-04 02:34:32 +00:00
jschoubben bc64c5c187 ADR 0201 → 0202, and three issues from the night it shipped
The derived-value record is renumbered a second time: the key-value-buckets
record took 0201 while this waited to merge, as the bundles record took 0188
before it. Both times free when chosen, taken by the time it landed. cycle.py
caught it; three repositories cite this record, so the number matters.

225 — a provisioner has not been able to read its grant secrets since 01:30,
when a module's own code left its container and the files stayed root's. Four
thousand refusals, each worded as patience, and two consumers unserved. Not
from ADR 0202 or 0189, which landed hours later; dates in the report.

226 — the store's sweep stops at the first reference recorded with an address
and collects nothing. A guard that cannot tell 'I will not ask about this'
from 'it would not answer' stops the wrong amount of work.

227 — the photo app's admin client asks for the port the proxy holds. A module
pinned months behind carries everything its branch gained, the first time
anything makes it move.
2026-10-04 04:33:45 +02:00
mesh-admin be4b5777b8 Merge pull request 'Research 024 and ADR 0201: a module keeps its current state in key-value buckets' (#348) from feat/module-state-on-the-bus into main 2026-10-04 01:43:34 +00:00
mesh-admin d882b3568c Merge pull request 'Group 8: ADR 0201 (a provider declares what it derives, issue 124) and ADR 0189 (the store keeps what the records name, issue 108); issue 202' (#305) from feat/the-store-keeps-what-the-records-name into main 2026-10-04 01:30:47 +00:00
jschoubben a3523617d3 Review before merge: the multi-holder boundary, the window's open race, the sweep's bounds
ADR 0201 gains the boundary found reading it back: a consumer keeping several
holders of a deriving provider is refused, because the two ends have no way
to agree. ADR 0189 gains two consequences — the sweep is bounded because it
runs inside a build, and an apply arriving mid-window reopens it.

That last one is issue 224, recorded rather than fixed: the host's rule for a
stopped container is to replace it, and while-stopped is the first thing that
makes a stopped container intentional. Both candidate fixes are decisions with
their own cost. Nothing is worse than it was; the store has never collected.
2026-10-04 03:27:32 +02:00
jochen 6b4da63261 Research 024: the composed grants, checked against a server once built 2026-10-04 02:50:02 +02:00
jschoubben 0231974226 Rebased onto main: ADR 0188 renumbered to 0201, and issue 202's evidence re-taken
The bundles refactor took 0188 on main while this waited in a pull request,
and the mesh's own code cites that one, so this record moves. Only the number
moved; the decision is the one taken on 2026-10-02, and the record says so.

Issue 202 re-checked against the refactored main: the fault stands, and the
test that surfaced it now fails one step earlier on issue 203's new credential
guard. Proven again past both — mint the credential, compose twice, and all
eight of dnsmasq's resources appear only with the setting set. ADR 0164 is
noted as the decision that answers half of it, and is not built.
2026-10-04 02:44:58 +02:00
jschoubben 92c029d10e ADR 0189: the store keeps what the records name, and a maintenance step holds its writers still
Issue 108: the artifact store has never collected anything. Fifty-three
repositories on the machine that serves everything else, and the only outcome
of leaving it is a full disk reported as somebody else's failure.

The mesh decides what may go — from its own build records, so it never names
a digest it did not put there — and the store reclaims the bytes in a nightly
window with its server held still. Deletion on the one door takes nothing a
push did not already have.

Designs 18 and 20 amended; issue 108 resolved.

Also issue 202, found running the controller's suite: a module whose required
setting nobody set is left out of the machine in silence, and dnsmasq became
that module this morning.
2026-10-04 02:40:25 +02:00
jschoubben 2a60da821d ADR 0188: a provider declares what it derives for each consumer, and the mesh tells both ends
Issue 124: a value the mesh's own rule produced reached neither end as a
statement. The object store's provisioner derived each consumer's bucket in
its own code; all three consumers transcribed the rule into their own
definitions, one of them wrong, and each of the three also named the machine
it happens to run on.

A served value may now name the consumer the mesh is serving. Design 27
amended; issue 124 resolved.
2026-10-04 02:40:06 +02:00
jochen e1b0bbde91 Research 024 and ADR 0201: a module keeps its current state in key-value buckets
Events miss a machine that joins after them and replay history where only the
latest matters. A module now declares state it owns and reads; the controller
creates the buckets, the runtime serves them on the bundle's channel. Designs 32
and 25 amended; grants measured against a running server.
2026-10-04 02:36:59 +02:00
mesh-admin 8ca09c70d6 Merge pull request 'Issues 213 and 223 resolved' (#347) from issues/213-223-resolved into main 2026-10-04 00:19:22 +00:00
jochen db71b83711 Issues 213 and 223 resolved: the controller runs as a process, and genesis hands over to it 2026-10-04 02:19:09 +02:00
mesh-admin 9143d0b7c1 Merge pull request 'ADR 0200: genesis pivots to the controller as a container, and the first push hands it to a process' (#346) from decision/0200-genesis-pivots-to-a-container-and-hands-over into main 2026-10-03 23:41:36 +00:00
jochen 24a51a8e53 ADR 0200: genesis pivots to the controller as a container, and the first push hands it to a process 2026-10-04 01:41:30 +02:00
mesh-admin f0d7f91d90 Merge pull request 'Design 38: WP4c complete' (#345) from design/38-wp4c-complete into main 2026-10-03 23:35:31 +00:00
jochen 8578a06ca8 Design 38: WP4c complete, no module's own code runs in a container 2026-10-04 01:35:16 +02:00
mesh-admin 86083f9c9d Merge pull request 'Issues 215, 221, 222 resolved' (#344) from issues/215-221-222-resolved into main 2026-10-03 23:29:38 +00:00
jochen 63d328147e Issues 215, 221 resolved with live proof; 222 diagnosed and resolved 2026-10-04 01:29:32 +02:00
mesh-admin fead0ea440 Merge pull request 'Issues 219-223 and design 38 WP4c built' (#343) from issues/219-223-and-wp4c into main 2026-10-03 23:14:49 +00:00
jochen df503d1cff Issues 219, 220 resolved, 221 located, 222 and 223 opened; WP4c built and proven 2026-10-04 01:14:44 +02:00
mesh-admin c2fc829822 Merge pull request 'Issues 211, 212, 214, 216, 217 resolved; 215's fix recorded' (#342) from issues/211-217-resolved into main 2026-10-03 22:25:07 +00:00
jochen 8d8e5c9a7e Issues 211, 212, 214, 216, 217 resolved with their proofs; 215's fix recorded 2026-10-04 00:24:53 +02:00
mesh-admin affba60b79 Merge pull request 'Issues 219-221: the builder's ordering gaps' (#341) from issues/219-221-the-builder into main 2026-10-03 22:16:13 +00:00
jochen 9ddbc4c68e Issues 219-221: the builder's ordering gaps seen while rolling out issue 218 2026-10-04 00:16:08 +02:00
mesh-admin a972db91f0 Merge pull request 'Issue 218 resolved: the runtime follows its membership, and a refused subscription is not fatal' (#340) from issues/218-rollout into main 2026-10-03 22:14:10 +00:00
jochen 029698fdc8 Issue 218 resolved: the runtime follows its membership, and a refused subscription is not fatal 2026-10-04 00:14:04 +02:00
mesh-admin 895c2afad1 Merge pull request 'Issue 218: a mesh seat answered by a non-holder (located, fixed in mesh-controller#248)' (#339) from issues/218-a-mesh-seat-answered-by-a-non-holder into main 2026-10-03 21:32:24 +00:00
jochen 4b8c5e3b11 Issue 218 located: the controller issued a mesh seat to every claimant; fixed in mesh-controller#248 2026-10-03 23:31:13 +02:00
mesh-admin d362155401 Merge pull request 'Issue 218: a mesh seat is answered by a module on a machine that does not hold it' (#338) from issues/218-a-mesh-seat-answered-by-a-non-holder into main 2026-10-03 21:25:27 +00:00
jochen 557760e537 Issue 218: a mesh seat is answered by a module on a machine that does not hold it 2026-10-03 23:25:17 +02:00
mesh-admin 6b1ebd1d4a Merge pull request 'Issue 217: a refused announcement took down every container's runtime, and the console with it' (#336) from issues/217-a-refused-announcement into main 2026-10-03 21:20:06 +00:00
mesh-admin fa73a17ceb Merge pull request 'Issues 211, 214, 215, 216 diagnosed' (#335) from issues/211-214-216-diagnosed into main 2026-10-03 21:19:03 +00:00
jochen 08cea893bd Issue 217: a refused announcement took down every container's runtime, and the console with it 2026-10-03 22:47:28 +02:00
jochen 04625d3e35 Issues 211, 214, 215, 216 diagnosed: root causes and the branches that fix them 2026-10-03 22:28:40 +02:00
mesh-admin d7bb24b181 Merge pull request 'ADR 0198: a module's long-running code is launched by the node's runtime and reaches the bus through it' (#334) from decision/0198-a-modules-long-running-code-is-launched-by-the-runtime into main 2026-10-03 20:21:04 +00:00
jochen 23d6e30b8a ADR 0198: a module's long-running code is launched by the node's runtime and reaches the bus through it; research 022; design 38 WP4c plan 2026-10-03 22:20:53 +02:00
mesh-admin 2043e90f35 Merge pull request 'Issues 214–216: a plan losing its own controller, a module pinned silently, a bundle never delivered' (#333) from issues/214-216-found-building-0192-0193 into main 2026-10-03 20:15:30 +00:00
jochen c9418af42e Issues 214–216: a plan losing its own controller, a module pinned silently, a bundle never delivered 2026-10-03 22:15:19 +02:00
mesh-admin 9c3e77999e Merge pull request 'Design 38 WP4d: every served bundle launched and node-tools in Go, proven live on all four machines' (#332) from design/38-wp4d-proven into main 2026-10-03 20:07:30 +00:00
jochen 6943843fff Design 38 WP4d: every served bundle launched and node-tools in Go, proven live on all four machines 2026-10-03 22:07:20 +02:00
mesh-admin 34325d3566 Merge pull request 'ADR 0197: every tool announces itself on the bus, in the NATS services protocol' (#331) from decision/0196-every-tool-announces-itself into main 2026-10-03 20:04:12 +00:00
jochen fa9e94d863 Renumber to ADR 0197: 0196 landed first on main 2026-10-03 22:03:58 +02:00
jochen 1b34821aa0 ADR 0196: every tool announces itself on the bus in the NATS services protocol 2026-10-03 22:03:34 +02:00
jschoubben 50ddaf8408 Merge pull request 'ADR 0196: a node asks the mesh's resolver first, and a public one only when it is silent' (#330) from decision/0196-nodes-ask-the-mesh-resolver-with-a-public-fallback into main 2026-10-03 20:01:05 +00:00
jschoubben f5d518d256 ADR 0196: a node asks the mesh's resolver first, and a public one only when it is silent
ADR 0194 rejected sending every query to the mesh's resolver because a node with its tunnel down
would resolve nothing; a public resolver listed second answers exactly then. That drops the
systemd-resolved stub and the runtime's dns: containers copy the machine's resolvers. Narrows 0194;
amends connectivity §2.
2026-10-03 21:56:33 +02:00
jschoubben 577ddf0089 Merge pull request 'ADR 0194: the mesh has one resolver, and every node asks it for the mesh's names' (#326) from decision/0194-the-mesh-has-one-resolver into main 2026-10-03 19:51:04 +00:00
jschoubben 13e28e6873 ADR 0194: the no-copies check allows each node's loopback stub 2026-10-03 21:51:03 +02:00
jschoubben 6b6ff76a19 ADR 0194: why every node needs a stub, and the systemd-resolved module that provides it 2026-10-03 21:50:33 +02:00
mesh-admin 8d5e6ef76f Merge pull request 'ADR 0195: the mesh's tools are found by address, not announced whole; research 021; to-be 34 §3a' (#329) from decision/0195-the-mesh-tools-are-found-by-address into main 2026-10-03 19:49:37 +00:00
jochen 77813f4613 ADR 0195: the mesh's tools are found by address, not announced whole; research 021; to-be 34 §3a 2026-10-03 21:49:26 +02:00
mesh-admin 4ee8e3905d Merge pull request 'Issue 213: the controller is a Go program and still runs in a container' (#328) from issues/213-the-controller-runs-in-a-container into main 2026-10-03 19:42:31 +00:00
jochen 216faec69e Issue 213: what the container gives it, measured 2026-10-03 21:42:22 +02:00
jochen e36b1a9e9c Issue 213: the controller is a Go program and still runs in a container 2026-10-03 21:42:11 +02:00
mesh-admin 5886969c75 Merge pull request 'Issue 212: a toolchain rebuild keeps the SDK it cached' (#327) from issues/212-a-toolchain-keeps-a-cached-sdk into main 2026-10-03 19:24:49 +00:00
jochen 5fa43ff755 Issue 212: a toolchain rebuild keeps the SDK it cached 2026-10-03 21:24:39 +02:00
jschoubben 1de4a5f25e ADR 0194: the mesh has one resolver, and every node asks it for the mesh's names
Every resolution fault found on 2026-10-03 was a per-node copy disagreeing with the truth: a hosts
file read once, an operator's old line beside the mesh's, a node's resolver lent to a LAN. Every
tunnel already converges on one node. Retires node-dns-resolver for a mesh-scoped mesh-resolver;
nodes route only the mesh's suffix to it. Narrows 0121; amends connectivity §2 and the seats.
2026-10-03 21:21:49 +02:00
jschoubben 8a1fa37dce Merge pull request 'ADR 0191: domains are a node's — the resolver holds each node's internal domain, nothing else' (#323) from decision/0191-names-by-origin into main 2026-10-03 19:12:26 +00:00
mesh-admin 44eb13acd0 Merge pull request 'ADR 0193: every bundle the runtime serves is launched, and the runtime knows no language; design 38 WP4d' (#325) from decision/0193-every-served-bundle-is-launched into main 2026-10-03 19:05:12 +00:00
jochen 6d53f9168a ADR 0193: every bundle the runtime serves is launched, and the runtime knows no language; design 38 WP4d 2026-10-03 21:05:03 +02:00
mesh-admin 6a1fc71a4c Merge pull request 'Design 38 WP4b: four tools-only modules proven live from the runtime; two delivery traps' (#324) from design/38-wp4b-seven-proven into main 2026-10-03 14:13:40 +00:00
jochen fb76fb7256 Design 38 WP4b: four tools-only modules proven live from the runtime; two delivery traps 2026-10-03 16:13:32 +02:00
jschoubben 2344bfb69b ADR 0191: domains are a node's — one internal, one or more public; the roster is the machines 2026-10-03 16:10:38 +02:00
jschoubben ca8a865e73 ADR 0191: the mesh's names are known by where they were composed, not by their suffix
A progressive insight: the rule and its check were stated as a suffix test; the mesh composes both
names of a route and publishes its internal one. The decision is unchanged.
2026-10-03 15:39:07 +02:00
mesh-admin 27c6881287 Merge pull request 'Issue 211: a bundle is built before the toolchain it is compiled in; ADR 0192 progressive insight; design 38 WP4b built, WP4c opened' (#322) from issues/211-and-0192-insight into main 2026-10-03 13:36:43 +00:00
jochen f8458d6f2c Issue 211: a bundle is built before the toolchain it is compiled in; ADR 0192 progressive insight; design 38 WP4b built, WP4c opened 2026-10-03 15:36:26 +02:00
mesh-admin c5778f4366 Merge pull request 'ADR 0192: a tools bundle declares what it is given, and the runtime hands it to that bundle alone; research 020 graduated; design 38 WP4b' (#321) from decision/0192-what-a-bundled-tool-is-given into main 2026-10-03 13:18:40 +00:00
jochen 4c1ad0ed45 ADR 0192: a tools bundle declares what it is given, and the runtime hands it to that bundle alone; research 020 graduated; design 38 WP4b 2026-10-03 15:18:22 +02:00
jschoubben 9873e951a9 Merge pull request 'ADR 0191: the mesh resolves only its own names; a public name resolves publicly' (#320) from decision/0191-the-mesh-resolves-only-its-own-domain into main 2026-10-03 13:16:13 +00:00
jschoubben e5e6e56ecf ADR 0191: the mesh's resolver holds only the mesh's own names; a public name resolves publicly
Publishing every routed public name at a private address turned ace's LAN-facing resolver into an
outage for non-members: a phone got the control-node's tunnel address for the mail server. Routes
have internal names since 0151 and the proxy certifies public names publicly, so nothing needs the
private answer. Narrows 0066 and 0151; amends connectivity §2 and §5.
2026-10-03 15:11:45 +02:00
mesh-admin 65c30c576a Merge pull request 'Research 020: what a bundled tool is given; design 38 WP4: fail2ban followed, proven live' (#319) from research/020-what-a-bundled-tool-is-given into main 2026-10-03 13:11:24 +00:00
jochen ee17cddb74 Research 020: what a bundled tool is given; design 38 WP4: fail2ban followed, proven live 2026-10-03 15:11:08 +02:00
mesh-admin 22e96e5bc7 Merge pull request 'Issue 210 resolved by mesh-host #80: an unchanged process keeps its record' (#318) from issues/210-resolved into main 2026-10-03 11:56:30 +00:00
jochen 7e464e3b22 Issue 210 resolved by mesh-host #80: an unchanged process keeps its record 2026-10-03 13:44:49 +02:00
mesh-admin 502763ce5e Merge pull request 'Issue 209: a bundle's own SDK copy registers into a registry the runtime never reads; design 38 WP4 note' (#316) from issues/209-a-bundles-own-sdk-copy-registers-nowhere into main 2026-10-03 11:20:38 +00:00
jochen eaae0e2b80 Merge remote-tracking branch 'origin/main' into issues/209-a-bundles-own-sdk-copy-registers-nowhere 2026-10-03 13:20:33 +02:00
mesh-admin 2ee6eab7b9 Merge pull request 'Issue 210: the host re-creates the node's runtime on every reconcile, restarting it every ten minutes' (#317) from issues/210-the-host-re-creates-the-runtime-every-cycle into main 2026-10-03 11:20:27 +00:00
jochen ce5f85f65e Design 38 WP4 built and proven live on all four machines; issue 209 proven live 2026-10-03 13:20:12 +02:00
jochen 911125148c Issue 210: the record of the runtime's process carries no digest; the credential and the unit text are ruled out 2026-10-03 13:13:25 +02:00
jochen d718a917e5 Issue 210: the host re-creates the node's runtime on every reconcile, restarting it every ten minutes 2026-10-03 13:11:17 +02:00
jochen a572868333 Issue 209 resolved by mesh-tools #32 2026-10-03 13:07:41 +02:00
jochen 55443b67e6 Design 38 WP4: what the review of mesh-catalog #239 found — the credential goes, iptables is declared, escalation is an unchecked machine fact 2026-10-03 13:00:35 +02:00
jochen 89a202f12e Issue 209: a bundle's own SDK copy registers into a registry the runtime never reads; design 38 WP4 note
Found preparing WP4, before the first module's bundle was loaded beside the runtime's own:
proven with a two-copies probe, located in mesh-tools (node-tools), fixed by mesh-tools #32.
Design 38 WP4 records it and the two things the package left to the module — escalation
through sudo, and the filter file read from the manifest's path rather than a container env.
2026-10-03 12:46:51 +02:00
mesh-admin b342c9c3da Merge pull request 'Issues 203, 206 and 207 resolved by mesh-controller #233; 207's diagnosis, with its two questions left open' (#315) from issues/203-206-207-resolved into main 2026-10-03 09:50:56 +00:00
jochen 336b8c9b62 Issues 203, 206 and 207 resolved by mesh-controller #233; 207's diagnosis, with its two questions left open 2026-10-03 11:50:20 +02:00
mesh-admin 0c82909845 Merge pull request 'Issues 204 and 205 resolved: mesh-controller #232 and mesh-host #79 merged' (#314) from issues/204-205-resolved into main 2026-10-03 09:46:22 +00:00
jochen d8a0c1b02f Issues 204 and 205 resolved: mesh-controller #232 and mesh-host #79 merged 2026-10-03 11:45:19 +02:00
mesh-admin b232b44f4c Merge pull request 'Design 18 and ADR 0190: shared build work proven live on all four machines, and the five gaps the switch found' (#313) from design/18-shared-build-work-proven-live into main 2026-10-03 09:44:19 +00:00
jochen c03f2cd4c6 Design 18 and ADR 0190: shared build work proven live on all four machines, and the five gaps the switch found 2026-10-03 11:43:53 +02:00
mesh-admin 73c4d24024 Merge pull request 'Issues 203–206 diagnosed and located' (#310) from issues/203-206-diagnosed into main 2026-10-03 09:43:00 +00:00
mesh-admin 3372d72da0 Merge pull request 'Issue 208: a seat's worker is made only when the controller starts, so a holder assigned later finds none' (#312) from issues/208-a-holder-assigned-later-finds-no-worker into main 2026-10-03 09:33:28 +00:00
jochen 273b932329 Issue 208: a seat's worker is made only when the controller starts, so a holder assigned later finds none 2026-10-03 11:33:08 +02:00
mesh-admin f91a3efb6b Merge pull request 'Issue 207: a re-made worker replayed every ask the stream kept, and the mesh re-registered its past' (#311) from issues/207-a-re-made-worker-replayed-history into main 2026-10-03 09:06:17 +00:00
jochen 2f0ce4ce19 Issue 207: a re-made worker replayed every ask the stream kept, and the mesh re-registered its past 2026-10-03 11:05:40 +02:00
jochen bf3338898e Issue 204: diagnosed and located — a declaration was numbered after composing, and a dying sender recorded nothing 2026-10-03 04:03:51 +02:00
jochen 4891cdeac5 Issues 203, 205, 206: diagnosed and located 2026-10-03 03:51:41 +02:00
mesh-admin 733cff4c3c Merge pull request 'Issue 206: a seat's worker changing type strands its holder, and the build that would fix it' (#309) from issues/206-a-worker-changing-type-under-its-holder into main 2026-10-03 01:46:12 +00:00
jochen 561f26fb34 Issue 206: a seat's worker changing type strands its holder, and the build that would fix it 2026-10-03 03:45:33 +02:00
mesh-admin d8a58dadcf Merge pull request 'ADR 0190: a seat's work is shared by its holders, and building is the first such role; design 18 says where a build runs' (#306) from decision/0190-a-seats-work-is-shared-by-its-holders into main 2026-10-03 00:44:27 +00:00
mesh-admin 3f4782cfb4 Merge pull request 'Design 38: WP3 proven live on all four machines; the three issues it found' (#308) from design/38-wp3-proven-live into main 2026-10-03 00:41:47 +00:00
jochen d076647b5d Design 38: WP3 proven live on all four machines; the three issues it found 2026-10-03 02:41:24 +02:00
mesh-admin ae83c5e09b Merge pull request 'Issues 203–205: what the first node-tools rollout found' (#307) from issues/203-205-what-the-first-node-tools-rollout-found into main 2026-10-03 00:41:07 +00:00
jochen feeea127e2 Issues 203–205: what the first node-tools rollout found — an unissued credential pushed, a stale declaration during a controller handover, a package resource against a stale database 2026-10-02 23:47:12 +02:00
jochen 76b563e0ce ADR 0190: the ack-wait race is issue 175 2026-10-02 22:29:53 +02:00
jochen f56686d1e5 ADR 0190: a seat's work is shared by its holders, and building is the first such role; design 18 says where a build runs
The build role was a mesh seat with one holder and its worker a push consumer with one delivery in
flight, so thirty-five images rebuilt serially on one machine while three others idled. The bus was
drawn for the alternative — a seat's accept subjects on a queue group of holders (design 25) — and
0190 uses it as one pattern for every role: a node seat with accepts, every holder pulling one ask
when idle, the asker addressing the role. Building is the first use: node-build-agent, held by the
build-agent module on every machine with a container runtime. Notes in 0121 and 0162 where their
facts went stale.
2026-10-02 22:29:08 +02:00
mesh-admin 5938d40dee Merge pull request 'ADRs 0164–0166 and issue 190: the container runtime gets a module, a seat and declared settings' (#271) from decision/docker-module into main 2026-10-02 20:14:58 +00:00
mesh-admin f062672f83 Merge pull request 'Issues 192 and 193: the console reaches a person only by hand; the store's read-only query is not' (#272) from issues/192-193-console-registration-and-store-query into main 2026-10-02 20:14:46 +00:00
jschoubben e4a0c73e2b Merge remote-tracking branch 'origin/main' into issues/192-193-console-registration-and-store-query 2026-10-02 22:14:24 +02:00
jschoubben d1aeee42a4 Regenerate the decision index after merging main 2026-10-02 22:14:21 +02:00
mesh-admin 81d780f973 Merge pull request 'Design 38: WP3 built — node-tools beside mesh-tools, the three things the plan did not say, and the gate refuses spreading not standing' (#304) from design/38-wp3-built-and-the-gate-softened into main 2026-10-02 19:57:16 +00:00
jochen 3e30846e0f Design 38: point at ADR 0069's real file name 2026-10-02 21:46:34 +02:00
jochen b3f18c54c6 Design 38: WP3 built — node-tools beside mesh-tools, the three things the plan did not say, and the gate refuses spreading not standing 2026-10-02 21:46:01 +02:00
mesh-admin e11bf320c9 Merge pull request 'ADR 0188: a module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime; design 38 gains WP1b' (#300) from decision/0187-a-modules-own-code-is-bundles-in-any-language into main 2026-10-02 19:27:39 +00:00
jschoubben da8b4b4ee4 Renumber to ADR 0188: 0187 landed on main first, as the dead-tracker record
Two records shared 0187 (issue 155's collision); the branch landing last
renumbers, and this is it. Only the number changes.
2026-10-02 21:01:02 +02:00
jschoubben c026d5221e Merge remote-tracking branch 'origin/main' into renumber-0187 2026-10-02 21:00:45 +02:00
jochen 709240ec1f ADR 0187: a module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime; design 38 gains WP1b
The operator's direction, absent from every record until now: the SDK must not limit who writes a
module; tools and services may be written in any language; one module may ship several bundles
(tools, a seat's implementation, a daemon); skeleton first, a full implementation when the work
requires it. ADR 0175 had the runtime import a bundle, which only JavaScript can be.

0187 makes a tools bundle a process the node's runtime launches and speaks MCP over stdio to —
the vocabulary the runtime already speaks outward — so any language with an MCP library can write
one today and the mesh's SDK per language is thin; the transport stays in the runtime (0039's
refusal, kept). Importing a TypeScript bundle is the shortcut, not the contract. Notes in 0175,
0039 and 0150 say where their mechanism moved; design 38 records WP1 as built and adds WP1b (the
launcher and the skeleton SDKs); the glossary's bundle widens.
2026-10-02 18:56:57 +02:00
jschoubben 17ca9a262b 0164: a setting names the file it lands in (issue 198's leak between one module's files); 190 notes the fourth machine now has the resolver 2026-10-02 12:23:28 +02:00
jschoubben 967c793eaa Merge remote-tracking branch 'origin/main' into decision/docker-module 2026-10-02 12:23:27 +02:00
jschoubben 27c1db8a86 Review of 0164-0166 and 190: the mesh's own setting words stay settable; changing runtime verbs are not the console's wildcard; migration steps 1-2 are one push; dnsmasq's dns key dates from 09-23 2026-10-02 00:48:06 +02:00
jschoubben 24aeb203f7 Issue 193 resolved: both readers live on every machine, checked by asking each copy who it is 2026-10-02 00:35:03 +02:00
jschoubben afbfd5f29d Issue 193: mssql's variable substitution and shell commands, proven and fixed by mesh-catalog PR 210 2026-10-02 00:25:48 +02:00
jschoubben 696957aa5e Issue 193: proven on a throwaway server, fixed for postgres by mesh-catalog PR 209; mssql has the same hole 2026-10-02 00:09:38 +02:00
jschoubben 3d54fcbb86 Merge remote-tracking branch 'origin/main' into decision/docker-module 2026-10-02 00:02:57 +02:00
jschoubben b13ef1be81 Issues 192 and 193: the console reaches a person only by hand; the store's read-only query is not
192: no provision says where the console is, its port was never assigned, and nothing
owns a person's agent configuration since the predecessor left. 193: the query verb wraps
the caller's text in a read-only transaction the text can end, and its rows come back
keyed by BEGIN.
2026-10-02 00:02:51 +02:00
jschoubben f1941304cc ADRs 0164-0166 and issue 190: the container runtime gets a module, a seat and declared settings
Proposed for the operator's review: settings declared with defaults and cost (0164),
container-runtime as a kernel capability (0165), node-container-runtime seat with the
host creating containers through its holder (0166), and the runtime's file written by
modules that are not its own (190).
2026-10-01 23:13:18 +02:00
117 changed files with 7907 additions and 120 deletions
+7 -1
View File
@@ -78,6 +78,12 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
mesh-scoped exclusive claim is how the mesh says "there is one of me". A foundation seat is mesh-scoped exclusive claim is how the mesh says "there is one of me". A foundation seat is
named after the server it guards: the `mesh-controller`, `postgres` and `lavinmq` modules claim named after the server it guards: the `mesh-controller`, `postgres` and `lavinmq` modules claim
the `mesh-controller`, `mesh-store` and `mesh-broker` seats ([ADR 0079](../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)). the `mesh-controller`, `mesh-store` and `mesh-broker` seats ([ADR 0079](../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)).
- **depends on a seat** — a module needing a seat held on its node by some module, without holding
it. Derived from the resources it declares, never stated: a `service` depends on
`node-service-manager`, a `package` on `node-package-manager`, a `container` on
`node-container-runtime` ([ADR 0207](../02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)).
Not a claim: a module **claims** a seat it holds and **declares** resources. Nothing claims a
package.
- **provision** — a service one module `provides` and others `require`; the mesh resolves a provider - **provision** — a service one module `provides` and others `require`; the mesh resolves a provider
and wires the two with an endpoint and a credential. A provision is a service you offer, a seat and wires the two with an endpoint and a credential. A provision is a service you offer, a seat
is a role you occupy, and the two meet where a seat delivers a provision: occupying the seat is is a role you occupy, and the two meet where a seat delivers a provision: occupying the seat is
@@ -108,7 +114,7 @@ term retired here may still appear there, and the mapping above is how to read i
memberships issue; its serving mode on loopback is what was called **the console** 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)). ([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. Replaces **"console"** as the module's name; *console* remains the word for the person's end of it.
- **bundle** — the artifact a module's tools are built into, interpreted or compiled; never an image. - **bundle** — the artifact a module's own code is built into — its tools, a seat's implementation, a daemon — in any language the mesh has a toolchain for, interpreted or compiled; never an image. One module may declare several ([ADR 0188](../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)).
- **kept region** — a marked block in a managed file the mesh writes *into*, where the operator's own - **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 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)). ([ADR 0174](../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
@@ -0,0 +1,34 @@
---
status: graduated
initiated: 2026-10-03
touches: [the tool runtime, the catalogue's tool bundles, the controller's declaration composer, settings, own secrets, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
became: [02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
---
# 020 — What a bundled tool is given
## What is being investigated
How a module's tools, once they are a bundle the node's runtime loads
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
[ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)),
learn the things their container used to be handed: where the module's configuration file is, where
its token or password is, which port the service listens on, where a provision's address is written.
A container is given these as an environment and mounts, composed by the mesh per module per machine.
A bundle has no environment of its own: the runtime's process carries four words for every bundle it
loads, and nothing per module ([design 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4).
## Why
Two holders moved on 2026-10-03 — the packet filter and the intrusion prevention — and both could,
because neither needs anything but a fixed path and root. Of the thirty-three modules whose tools
still run as containers on the runtime's image, thirty-one are not like that: their environment
names a configuration file, a credential file, a service address, a grants directory. Moving them
one by one without a rule for this would give the mesh thirty-one answers to one question. The
measurement and the options are in [01](01-what-the-containers-are-given.md).
## What it touches
The runtime (which hands a bundle what it is given), the composer (which resolves `${dir:…}` and
`${port:…}` for a container today and would for a bundle), the manifest (where a bundle would say
what it needs), and design 38, which records the gap and must say the rule once there is one.
@@ -0,0 +1,86 @@
# What the tool containers are given, measured
Counted 2026-10-03 in the catalogue, after the two holders moved.
| | |
|---|---|
| modules whose tools still run as a container on the runtime's image | 33 |
| tool containers among them (two modules run two) | 36 |
| modules whose container's environment carries only the bus credential | 1 (the intrusion prevention, now moved) |
| modules whose container's environment carries more | 32 — 31 still containers |
## What "more" is
Every value a container is given is one of five shapes. The reference kinds the composer resolves
in those values, over the 36 containers: a module directory (`${dir:…}`) in all 36, a mesh-chosen
port (`${port:…}`) in 12, a seat and an access grant once each.
1. **A file the mesh already places on the host, mounted in.** The module's configuration as
JSON (`…_CONFIG_FILE`), its own secret (`…_TOKEN_FILE`, `…_PASSWORD_FILE`, `MESH_BROKER_FILE`),
a provision's address and secret written for it. Every one is a path under one of the module's
directories — its mesh state, its state, its grants, what it has written — mounted at a path of
the container's choosing and named to the tool through the environment. **The file is on the
host already; only the name under which the tool finds it is the container's.**
2. **The service's address, with the port the mesh chose:** `http://127.0.0.1:${port:3000}`. The
port is the composer's; the rest is the manifest's constant.
3. **A provision's address as a constant string** (a database's URL on the module's own network
name), paired with a mounted secret file from shape 1.
4. **A directory of grants** (`MESH_RECEIVES`): shape 1 again, a directory rather than a file.
5. **Literals the image needs:** a time zone, a user id, a memory limit. These belong to the
service's container where one exists; a tool bundle needs none of them.
So the whole of what a bundled tool needs is: the paths of its module's directories on this
machine, the ports the mesh chose for its module here, and the constants its own manifest wrote.
Nothing a container had that a bundle cannot have; the mesh composes all three for the container
today, per module per machine.
## What the runtime already has for it
- The SDK's tool contributor is `(env) => tools`, and `collectTools(env)` takes the environment to
hand each contributor. The runtime calls it without one, so every contributor reads the process's
— the four words. The hook for a per-module environment exists and is unused.
- A launched bundle ([ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md))
is spawned with the runtime's environment; the launch takes an environment argument.
- The composer resolves `${dir:…}` and `${port:…}` for a container's `env` and `volumes`; the same
resolution over a bundle's declaration is the same code.
## Options
**A. The bundle declares its environment on its artifact, and the mesh composes it as a
container's.** The manifest's tools artifact gains `env`, resolved with the same references;
values that were mount targets become the host-side paths directly (`${dir:mesh-state}/config.json`
rather than `/run/config/config.json`). The controller composes one environment per bundle per
machine into the runtime's declaration; the runtime hands it to the bundle's contributor and to a
launched child, and to nothing else. *For:* the tool code does not change — it reads the same
names; the conversion of the thirty-one is a mechanical move of the container's `env` with the
mounts folded in; one rule, one place. *Against:* the runtime's process carries thirty-one
environments in its declaration, and a bundle's environment is visible to the other bundles in the
process unless the runtime keeps them apart, which it must — a tool that reads `process.env`
instead of the environment it was handed would see its neighbours' paths.
**B. The runtime derives the environment from the module's placed manifest.** No new field: the
runtime reads, for each module it serves, where that module's directories and ports are, and hands
a conventional set of words. *For:* nothing to declare. *Against:* a convention the tool code must
be rewritten to, thirty-one times; the runtime learns the composer's job; a module that names its
file `config.json` and one that names it `settings.json` need different words anyway.
**C. Tools read their module's files through the bus** — ask the controller. *Against:* a tool
that cannot start without the bus answering a question is a tool that fails in the one case the
tools exist for, and a secret crossing the bus to reach a file already on the machine is a
disclosure for nothing.
A is the one that keeps the tool code and the composer's vocabulary as they are, and names the one
thing the runtime must add: an environment per bundle, kept apart. The thing to decide beside it:
whether a bundle's environment may name a secret file at all, or whether secrets stay mounts in
spirit — a path the tool reads, never a value in the environment — which is what every container
does today and what A keeps if the rule says *paths, not values*.
## What a decision would have to say
- Where a bundle says what it is given (the artifact, option A), and that values are paths and
constants, never a secret's content.
- That the composer resolves it with the references it already has, per module per machine.
- That the runtime hands each bundle its own environment and nothing of another's, and how that is
checked: a test loading two bundles whose environments differ and asserting each sees only its own.
- That the thirty-one move in one mechanical change after the rule lands, each proven by its tools
answering from the runtime, and the registration gate then refuses the container shape for all.
@@ -0,0 +1,48 @@
---
status: graduated
initiated: 2026-10-03
touches: [the console, 03-DESIGN/01-to-be/34-the-console.md, the tool runtime, seats, assignments]
became: [02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md, 03-DESIGN/01-to-be/34-the-console.md]
---
# 021 — Finding a tool in the mesh
## What was investigated
How an agent finds the one tool it needs among everything the mesh answers, and how a call names
exactly what it asks — a role the mesh holds once, a role every machine holds, or one assignment of a
module on one machine — rather than receiving the whole catalogue and a name that can mean several
things.
## Why
The operator's observation on 2026-10-03: *Claude should not see all tools at once; they should be
discoverable — and `postgres.list_databases` is wrong, asking one machine's postgres is not asking
another's.* Measured the same day from the console's own answer:
| | |
|---|---|
| tools announced to every session at its start | 228, in 110 KB |
| names (module or seat prefixes) | 43 |
| node seats' verbs, which require `node` | 22 |
| modules with tools on more than one machine | 4 — fail2ban, nftables (every machine), postgres, mssql (two each) |
| modules reported "not answering", most with no tools and several retired | 47 |
The two stateful modules on two machines are listed **once**, with `node` optional and *whichever
answers* when it is left out — though their two instances hold different databases. Design 34 §3 says
such a module is listed once per machine; the live console does not do that. The list is taken once
per session, so a tool that arrives later is invisible until the client reconnects. And only Claude
Code's own deferral of long tool lists keeps the 228 from the model's context; another MCP client
would receive them whole.
## Options
1. **Keep the flat list; rely on the client to defer it.** Rejected: a property of one client, and it
leaves the ambiguity and the stale list.
2. **One flat tool per assignment** (`ace_postgres_list_databases`). Removes the ambiguity, multiplies
the list, and runs into the API's tool-name limit (letters, digits, `_`, `-`, 64 characters).
3. **A small fixed set of tools that walk the mesh's own structure**, with the full address as an
argument: the mesh's seats; a machine's node seats and assignments; a search; a description; a
call. Chosen — see [ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md).
4. **MCP resources or prompts for discovery.** Clients support them unevenly, and an agent acts
through tools; a resource it cannot be relied on to read is not a discovery path.
@@ -0,0 +1,41 @@
---
status: graduated
initiated: 2026-10-03
touches: [the tool runtime, the per-module containers, the SDK, the bus grants, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
became: [02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
---
# 022 — Where a module's long-running code runs
## What was investigated
Twenty-three modules still run their own code in a container built on the runtime's image. Their tools
can move as bundles ([ADR 0192](../../02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md),
[ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md));
the rest of what those containers run cannot yet. This asks where that code goes and how it reaches
what its container handed it.
## What that code is, measured 2026-10-03
| | modules |
|---|---|
| subscribes to events on the bus | audit-logger (everything), mesh-catalog (two seat events), mesh-vault, records (`gitea.pull.merged`), and postgres, mongodb, mssql, redis, mosquitto logging their own lifecycle |
| provisioners: read the grants the mesh delivered as files, act on the backend, emit | 12 |
| a run-once preparation step | mesh-catalog |
| a command-line client of the backend | psql, mosquitto_ctrl, git (packages on every machine's system); mongosh, sqlcmd (not in its repositories) |
| a service reached by a container name | icecast, mailu-admin, minio, mongodb-server, mssql |
| a main of its own | anthropic-consumer, openai-consumer, route-adapter |
A provisioner needs nothing a launched bundle lacks: files named by its words, its backend, and an emit
that already travels through the runtime. The one thing missing is **a subscription** — events
delivered to the module's code, acknowledged when it has handled them.
## Options
1. **The runtime launches it and is its bus**: the stdio channel gains a subscription; the runtime
binds the module's durable consumer and delivers each event to the child, acknowledging when the
child answers. One bus connection per machine; any language. Chosen.
2. **A process per module with its own bus client and credential.** Every language's SDK would carry
a transport and every module a credential on disk — what ADR 0188 rejected for tools, for the same
reasons.
3. **Keep the containers for this code.** Leaves ADR 0188's rule broken for 23 modules indefinitely.
@@ -0,0 +1,152 @@
---
status: graduated
initiated: 2026-10-04
touches: [the bus, what a module declares, the tool runtime, the SDK, the bus grants, 03-DESIGN/01-to-be/25-the-bus-on-nats.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md]
became: [02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md, 03-DESIGN/01-to-be/25-the-bus-on-nats.md]
---
# 024 — State a module keeps on the bus
## What is investigated
A place on the bus where a module's own code keeps **current state** — not history — that every
machine sees, including a machine that joins after the state was written: put, get, delete, list and
watch, reached through the node's runtime the way a bundle already publishes, asks and subscribes
([ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)).
On NATS that is a key-value bucket. The questions are what a module declares, who creates the
bucket, what the grants are, what the runtime's verbs are, and what may never be stored.
## Why
The mesh carries two kinds of module traffic and a third is missing.
- **Events** land in the EVENTS stream: limits retention, seven days, ten thousand messages per
subject, a durable consumer per consuming module that replays what it missed. Never a secret
([design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10).
- **Requests** are core request/reply — tool calls, a bundle's `mesh/ask` — and are kept nowhere.
Neither is *the current value of something*. Two cases from the first module that needs it, the
operator's agent on a machine ([design 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)):
1. **An MCP server registered for every machine.** Registering emits an event every machine's copy
of the module consumes. A machine the module is assigned to *after* the registration has no
durable consumer yet — the consumer is created at assignment — so it never hears of it. Wanted
instead: one entry per server, for every machine or for one; every machine reads the whole current
set when it starts and watches for changes; unregistering is a delete; any machine can list it.
2. **Which licence a machine is bound to** ([design 39](../../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md)).
As events, a machine that was off for a day replays every rotation since and asks for a token
after each. It needs only the latest binding and its generation. The token itself stays on
request/reply and is never stored.
The design already expects this. [Design 25](../../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1:
"conditions and observed state in key-value buckets that anything may watch".
[Research 017](../017-a-mesh-that-heals-itself/01-the-intended-behaviour.md) wants a provisioner's
"what I applied" and a rotation's step kept in one rather than in memory. Nothing implements it.
## What exists, measured 2026-10-04
| | fact | where |
|---|---|---|
| streams | five kinds of mesh stream: CONTROL (work queue), NODES and ASSIGNMENTS (last per subject), EVENTS (limits: 7 days, 10 000 per subject), one work queue per seat that accepts | the controller's broker streams |
| the state relationship | [design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 already names *state* — 1:1, last per subject — and says it is "declared: the mesh's own". Two streams use it, both written by the controller. No module can declare it | design 32, the controller |
| key-value buckets | none, anywhere | all four code repositories |
| the runtime's bus verbs | `mesh/publish`, `mesh/ask`, `mesh/subscribe`; delivery back to the bundle is `mesh/event` | the runtime's launcher |
| the runtime's principal | one bus user per machine carries every assigned module; its grant is the union of theirs. That one module's code does not act as another is the runtime's to keep: it publishes under the module's own name by construction | the controller's grant composition, the runtime's bus |
| what a bundle is issued | a membership per assignment, last per subject, read directly by the runtime: where it serves, where it emits, what it reaches | ADR 0160 |
| who creates bus objects | the controller only — mesh streams on every raise, a seat's stream at registration, a module's consumer at assignment. No module reaches the JetStream API | design 25 §3 |
### What a key-value bucket needs from a grant, against a real server
Measured against nats-server 2.10 with the Go client the runtime already uses, a bucket created by
an unrestricted user and used by two users holding only the subjects below (`B` is the bucket):
| operation | subject published | writer | reader |
|---|---|---|---|
| bind to the bucket | `$JS.API.STREAM.INFO.KV_B` | yes | yes |
| get | `$JS.API.DIRECT.GET.KV_B.>` | yes | yes |
| put, delete | `$KV.B.>` | yes | **refused** |
| list keys, watch | `$JS.API.CONSUMER.CREATE.KV_B.>` — an ordered, ephemeral consumer | yes | yes |
| stop a watch cleanly | `$JS.API.CONSUMER.DELETE.KV_B.>` | yes | yes |
| answers | its own inbox, which every principal already subscribes | — | — |
*Checked again once built, 2026-10-04:* the grants the controller composes for two machines' runtimes —
one carrying the owner, one only a reader — were loaded into a server as composed, and each operation
was run as each runtime's user. The owner's did all of them; the reader's read, listed and watched,
and its put and delete were refused by the server.
Three things the measurement showed that reading the documentation would not have:
1. **A refused put is not an error to the caller; it is a timeout.** The server reports the
permission violation asynchronously, on the connection, and the client waits out its deadline
for an acknowledgement that never comes. So a runtime that relies on the grant alone tells a
bundle "timed out" for "you may not write this" — it must refuse first, from what the module was
issued, with the reason.
2. **A watch's current values include deletions.** A key deleted earlier arrives among the initial
values as a delete marker, before the end-of-current marker. A bundle asking "what is there now"
must not be handed those.
3. **Without the consumer-delete grant, stopping a watch hangs** until its deadline, and the
ephemeral consumer lingers on the server until it times out by itself.
### Whether the events shape is enough instead
Honestly compared, because a new primitive is a cost:
- **EVENTS cannot be made last-per-subject for some subjects.** Retention is per stream, and
JetStream refuses a second stream overlapping the first (verified and recorded in design 32 §3).
A state subject inside `mesh.mod.*.event.>` keeps EVENTS' seven days: a licence binding unchanged
for a week disappears.
- **A separate last-per-subject stream per module** is possible — it is exactly what a key-value
bucket *is* on the server: a stream with one message per subject, a rollup for purge, and direct
reads. Building it by hand gives up the client's get, list, delete and watch, which are the
operations both cases need, and would be the mesh writing NATS's own key-value layer again.
- **Consumers are the wrong reader.** A durable consumer per reading module is created at
assignment and replays from where it is; state wants "everything current, now, then changes",
which an ordered ephemeral consumer from the last value per subject gives and a durable does not.
So key-value is not a convenience over events; it is the state relationship design 32 already
names, opened to modules.
## Questions, and what this effort proposes
1. **What a manifest says.** `state` names the buckets a module owns, by local name — every
instance of the module may write them and read them. `reads` names another module's bucket as
`<module>.<name>`, read-only. Names only, never a bucket or subject (design 32 §1). A bucket's
options — how many past values it keeps, how long a value lives — are the owner's to declare,
the way a seat declares its own retention (design 32 §3).
2. **Scope.** One bucket per module per name, mesh-wide. A key may carry a machine by the module's
own convention (`all.<server>`, `<machine>.<server>`). A bucket per machine was considered and
not proposed: "list every server for every machine" becomes a walk over buckets, and the grant
could only narrow writes, which nothing asked for — every instance of the owner already writes.
3. **Who creates the bucket.** The controller, from the catalogue, on every raise — a bucket exists
from registration, like a seat's stream, so a reader can watch before the owner is assigned
anywhere. Never a module.
4. **The runtime's verbs.** `mesh/state.get`, `mesh/state.put`, `mesh/state.delete`,
`mesh/state.keys`, `mesh/state.watch`, each naming the bucket as the module named it. A watch
is answered once the current values are on their way, then each change is delivered to the
bundle as a `mesh/state` request it answers — current values first (no deletions among them), an
end-of-current marker, then changes. A child that restarts watches again, as it subscribes
again. The runtime refuses, with the reason, a bucket the module was not issued, and a write to
one it only reads.
5. **Secrets.** None in a bucket, sealed or not: a bucket is a stream (design 32 §10). Sealed values
are plain base64 and cannot be recognised, so the mechanical check is partial and said to be: the
runtime refuses a value carrying a field whose name says it is a credential (`password`,
`secret`, `token`, `authorization`, …), which catches the ordinary mistake and not a determined
one. For the first consumer this has a concrete consequence: an MCP server registered with an
authorisation header keeps that header out of the bucket.
6. **History, lifetime, size.** One value per key unless the owner says more; no expiry unless it
says one; a value at most 256 KiB and a bucket at most 64 MiB, the mesh's caps rather than a
module's. **A bucket outlives its module's assignment** — what a module stored is data, and data
outlives what declared it ([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md));
unassigning is not cleaning up. A bucket whose declaration is gone is reported, never removed.
7. **Events or state.** State (above).
## The work, once decided
1. A decision record, then design 32 (*state* becomes a relationship a module declares) and design
25 (key-value buckets are part of the bus) amended.
2. The controller: the manifest's two words and their registration check; buckets asserted on every
raise; the grants for owners' and readers' runtimes; the buckets issued in each membership.
3. The runtime: the five verbs, the watch delivery, the refusals; tested against a real server.
4. The SDK, TypeScript and Go: a small state surface over the verbs.
5. Proved on a running mesh with one small module, then handed to the operator's agent, whose
registered servers move from events to a bucket.
@@ -0,0 +1,94 @@
---
status: graduated
initiated: 2026-10-04
touches:
- 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/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.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
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
- 03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
became:
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
- 03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md
---
# 025 — How a module plugs into the operator's shell
## What is investigated
The shell module writes the mesh's part of the account's shell startup file. But the shell is not the
only module that needs a line there. A prompt theme loads itself from it. A language version manager
sets a variable and sources its loader. A toolchain puts its directory on `PATH`. A desktop module
names the browser. Today all of these sit in one hand-written file, and the shell module as written
carries some of them in its own block and loads others only "if a module placed them". Nothing says
how they get placed.
This effort asks four things:
1. **How a module contributes to the shell**: what it declares, who composes it, and in what order it
lands.
2. **Where the environment lives.** Variables and `PATH` entries are facts about the account, not
lines of one shell's syntax. They should reach every shell (interactive or not), the login shell's
`execute` verb, and programs a graphical session starts.
3. **Where the operator's own lines go,** so that assigning the shell module loses nothing the
machine does today.
4. **Which part of a file the mesh owns.** ADR 0174 calls the kept region the operator's; the host and
to-be 38 implement the inverse (the mesh owns a marked block, and everything outside it is the
operator's). The record this becomes says which.
## Why
Rolling out the shell module (to-be 38 WP5) was stopped on 2026-10-04 after a review of what assigning
it would do. Measured in [01](01-what-the-shell-file-holds-today.md):
- Every machine carries the same predecessor-written startup file, so the module's block would be
appended after its own older copy and everything would run twice.
- The block drops lines the machines rely on today.
- Nothing installs the prompt theme or the plugins the block loads.
- The `execute` verb runs a non-interactive login shell, which never reads the file the block is
written into.
The operator's direction: other modules must be able to plug themselves into the shell; the prompt
becomes its own module; assigning the shell module must lose no functionality; and the environment,
`PATH` above all, needs an answer of its own.
## What it touches
- The manifest. A contribution to the shell is either a new use of the existing `contributes` /
`receives` pair or a new gathered field like `jails` (to-be 31).
- The controller's composition, if the controller assembles the text.
- The `login-shell` seat (ADR 0176): what a holder must do with what is contributed to it, and
whether a module or the mesh declares the seat. Possibly a new seat for the environment, beside it
and beside the service manager's (ADR 0177). Research 023 asks the related question of a seat
naming the files its holder owns.
- ADR 0174's wording of the kept region, and ADR 0182's classification of the paths under a home.
- The zsh module, and the modules this makes possible: an environment module, the prompt, a version
manager, a toolchain.
## Where it stands
The operator proposed a separate **environment module**: one module, holding a mesh seat of its own,
that alone writes the account's environment. It writes a file that shells source and the service
manager's user environment, from the variables and `PATH` entries every other module contributes to
it. That is the starting position for the environment ([02](02-how-a-module-plugs-in.md) §1, option
E6). It leaves the shell's contribution as shell code only (§2), addressed to the `login-shell` seat,
which moves into the mesh's own seat set beside the new `node-environment` (§6).
Graduated on 2026-10-04 with one change from the starting positions: the controller, not the
environment module's own code, renders the environment into the module's files, so that the result
is in the declaration before a machine applies it ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
option 6b).
## Documents
- [01 — What the shell file holds today](01-what-the-shell-file-holds-today.md): evidence.
- [02 — How a module plugs in](02-how-a-module-plugs-in.md): the options and the starting position.
@@ -0,0 +1,103 @@
# 01 — What the shell file holds today
Measured 2026-10-04 on the four machines of one installation: two servers and two workstations. All
four have the account's login shell set to zsh, zsh installed from the distribution, and a
predecessor-written startup file. The predecessor is retired, so nothing manages these files any more.
## The startup file is the same everywhere
The account's `~/.zshrc` is **byte-identical on all four machines**: 102 lines, one checksum.
`~/.zshrc.local`, which the last line of `~/.zshrc` sources, comes in **two variants**: one shared by
both servers, and one shared by both workstations. So the "per-machine" part is really a
per-*kind*-of-machine part.
The predecessor produced these from one module with two *flavors*: a prompt flavor and an
autocomplete flavor, each of which swapped in a different local file. Its install hook also:
- cloned the prompt theme and three plugins from their upstream repositories into `~/.zsh/`;
- installed fonts;
- changed the login shell.
On the workstations the theme and plugins are still on disk, left over and now owned by nothing. The
servers have none of them.
## What the 102 lines are
Sorted by who should own each line once the machine is modules:
| Lines today | What they are | Natural owner |
|---|---|---|
| `EDITOR`, `VISUAL`, `XDG_CONFIG_HOME`, `PATH` gaining `~/.local/bin` and two script directories | the account's environment | the shell's default, or the environment itself |
| `PATH` gaining a toolchain's directory | environment, for one tool | the toolchain's module |
| a version manager's directory variable plus sourcing its loader | environment *and* shell code | the version manager's module |
| two variables naming the operator's own script library | environment, the operator's own | the operator |
| a variable that turns off an agent's terminal-title handling | environment, for one tool | the agent's module |
| the terminal title hook, keybindings, `dircolors`, the `ls`/`grep` aliases, `ll`/`la`/`l`, a container-run alias, two disk-usage functions, two port aliases | interactive shell behaviour | the shell's default |
| the prompt's instant-prompt cache, the theme, the prompt's own configuration file | shell code, order-sensitive (instant prompt first) | the prompt module |
| autosuggestions, syntax highlighting (and, unloaded, an autocomplete plugin on disk) | shell code, order-sensitive (syntax highlighting last) | a plugin module, or the prompt module |
| sourcing `~/.zshrc.local` | the operator's hook | the operator |
The workstation variant of the local file adds:
- more environment: a desktop toolkit theme, a file manager's plugin list, `BROWSER`, `VISUAL`
overridden to a graphical editor, a language toolchain's binary directory on `PATH`;
- two pieces of shell code: one that pads the prompt to the bottom of the terminal under a display, and
one that sources a function file another module places;
- a hook sourcing a further per-node file.
The server variant holds only that last module-placed source line.
**Count:** a workstation runs 65 non-comment lines from the two files (53 shared, 12 local); a server
runs 54. Of a workstation's 65:
- about a quarter (15) are environment;
- about half are interactive defaults no other module cares about;
- the remaining quarter is other modules' code and hooks (a prompt, plugins, a version manager, an
agent's functions), loaded from the shell file only because there was nowhere else to put it.
## The shell module as written
The `zsh` module of to-be 38 WP5 (catalogue change, unmerged):
- writes one block, appended at the end of `~/.zshrc`, holding a subset of the shared file:
- its environment lines, minus the toolchain directory, the version manager and the agent variable;
- the title hook, keybindings and the most common aliases, minus the port aliases;
- guarded `source` lines for the theme and two plugins *if present*;
- the source of `~/.zshrc.local`.
- assigned to any of the four machines, appends that block after the identical lines already there, so
every line in it runs twice, `~/.zshrc.local` included.
- on the servers, the guarded prompt lines find nothing; nothing installs the theme anywhere.
## Which startup file reaches what
zsh's startup order, and what each path through it reads:
| started as | reads |
|---|---|
| interactive login (a console, ssh with a terminal) | `.zshenv`, `.zprofile`, `.zshrc`, `.zlogin` |
| interactive non-login (a new terminal window) | `.zshenv`, `.zshrc` |
| non-interactive login: `zsh -lc …`, what the `execute` verb runs | `.zshenv`, `.zprofile`, `.zlogin`, **not** `.zshrc` |
| non-interactive: a script, `ssh host command` | `.zshenv` only |
So an environment written into `.zshrc` reaches neither `execute` nor a script. The distribution's
system-wide login profile, which zsh's system `zprofile` sources, only ever *appends* to `PATH` when an
entry is missing. An entry the account's `.zshenv` puts first therefore survives a login.
A graphical session's programs (a launcher, a bar, a window manager's key bindings) are started from the
display manager and the service manager, not from a shell, and read none of these files. The service
manager's own place for the account's environment is `~/.config/environment.d/`. Today it holds nothing
on any of the four machines, so a program launched from the window manager does not see `PATH` entries
that a terminal does.
## What the mesh already has for "many modules, one file"
Measured over the catalogue's 69 module definitions:
| mechanism | used by | shape |
|---|---|---|
| `contributes` / `receives` | 28 contribute, 15 receive | A consumer contributes **facts** keyed by a requirement. The provider receives all of them as one file in the mesh's own format, and **renders them itself**. "The controller does not know what a reverse proxy is." |
| `listens` / `filtering` | 40 declare listens, 1 composes | The controller derives the whole firewall rule set from every module's ports and writes it where the holder asks. |
| `jails` / `jailing` | 3 declare, 1 composes | Each module supplies its jail **in the tool's own format**. The controller assembles them, sorted, into the one file the holder names. |
| `into: block` on a file | 2 | One module's marked region inside a file something else owns. Text outside the region is kept byte for byte. Placement is at the end, or at the start. |
None of these is a contribution of shell code or of environment today.
@@ -0,0 +1,197 @@
# 02 — How a module plugs in
Six questions, taken one at a time: the environment, shell code, the operator's own lines, order,
who renders, and what a contribution is addressed to. Each has the options weighed and a starting
position. The positions were set with the operator on 2026-10-04 and are what this effort tests, not
what it has decided.
## 1. The environment: variables and `PATH`
A variable or a `PATH` entry is a fact about the account. It holds whichever shell is the login shell,
and it is wanted by:
- every shell, interactive or not;
- the login shell's `execute`;
- a graphical session's programs.
[01](01-what-the-shell-file-holds-today.md) measures that `.zshrc` reaches only the first kind, and
only interactively.
| | option | for | against |
|---|---|---|---|
| E1 | Each module writes lines into the shell's rc file (today) | nothing new | misses `execute`, scripts and the graphical session; written in one shell's syntax, so a second shell module starts over |
| E2 | A module contributes environment facts (a variable and its value; a `PATH` entry and its position) **to the login shell**. The holder renders them into its shell's always-read file (`~/.zshenv` for zsh) | reaches every zsh, `execute` included; a contributor names no path and no shell | the graphical session sees none of it; the environment is tied to which module holds the shell; every shell module reimplements the same rendering |
| E3 | E2, and the service-manager holder (ADR 0177) renders the same facts a second time into `~/.config/environment.d/` | the graphical session sees the same `PATH` as the terminal | one fact set, two owners, two renderings that can disagree; the service manager's module gains a duty unrelated to managing services |
| E4 | One composed file in `environment.d` syntax, sourced by the shell with export-all | one file, two readers | ties the shell to the service manager's syntax, which is close to POSIX assignments but not equal (its `${VAR:-default}` and quoting rules differ); a value with a space breaks one reader or the other |
| E5 | Shells take the environment from the service manager's environment generator, which prints the merged `environment.d` | no file of the shell's at all | every shell depends on the service manager and starts a process on every start; the generator's output is unquoted, so a value with a space breaks it |
| **E6** | **An environment module.** A module of its own (working name `node-env`) holds a mesh seat, `node-environment`, and is the only writer of the account's environment. Every module contributes its variables and `PATH` entries to that seat. The holder writes them in each reader's format: a POSIX file of `export` lines that shells source, and the service manager's `~/.config/environment.d/` | the environment no longer depends on which shell holds `login-shell`; one owner and one rendering per format, both from the same facts; the graphical session included without the service manager's module; a contributor addresses "the environment", never a shell; the `PATH` rules (order, de-duplication) live in one module's code, where a test can hold them | one more module and seat, assigned on every node beside the shell; the `login-shell` protocol gains a duty, to source the environment file, which must be written down and checked |
**Starting position: E6.** It was the operator's proposal on 2026-10-04, and it replaces this
document's first position (E2, then E3).
- The facts are the contribution. Each format is rendered once, by the one module whose subject is the
environment.
- A shell module's part shrinks to one line in its always-read file: `.zshenv` for zsh, sourcing the
environment module's POSIX file. A bash or fish module writes the same line in its own file, and no
contributor changes when the login shell does.
Sketched, for a node with zsh, the environment module, and a toolchain:
```
toolchain ──contributes PATH entry──▶ node-environment ◀──contributes EDITOR, ~/.local/bin── zsh
│ (held by node-env)
┌──────────────────┴──────────────────┐
▼ ▼
POSIX export file ~/.config/environment.d/
▲ ▲
sourced from ~/.zshenv read by the service manager
(every zsh, execute too) (the graphical session)
```
The shell module still contributes its own environment (`EDITOR`, `XDG_CONFIG_HOME`, `~/.local/bin` on
`PATH`) as a contributor like any other; it does not write those lines itself. Once issue 168 closes,
the values a person varies become settings of whichever module contributes them (ADR 0174).
## 2. Shell code: a prompt, plugins, a version manager's loader
This *is* one shell's syntax, and order matters: a prompt's instant-prompt cache must run first, and
syntax highlighting last.
| | option | for | against |
|---|---|---|---|
| S1 | **A contribution of code for one shell** (the shell it is for, the code, a slot), gathered by the controller and placed inside the holder's block in slot order. The same shape as `jails`, which a module supplies in fail2ban's own format and the controller assembles | a contributor names no path; the order is declared and checkable; unassigning the contributor removes its code at the next composition; a node holding fish simply has no zsh code rendered, and the resolver can say so | the controller gains one more gathered field; code for a shell travels in the declaration (in the clear, so no secrets in it, as for any file) |
| S2 | **A drop-in directory**: each module places its own `~/.zsh/rc.d/NN-name.zsh`, and the shell's block sources the directory | no controller change; each file is its module's own, removed when undeclared | every contributor hard-codes a path inside the shell module's territory, against ADR 0112's spirit; order is a naming convention nothing checks; nothing ties the file to the shell actually being zsh |
| S3 | Contributions as facts the holder renders (`contributes`/`receives` proper) | one mechanism with question 1 | code is not a fact; the holder would only paste it, which is S1 with an extra file |
**Starting position: S1.** A contribution to the shell carries **only code**, for named shells, each
piece in a slot. Variables and `PATH` entries never go here; they go to the environment (§1). So a
module touching both makes two contributions:
- A prompt module contributes zsh code in the first slot, and its own configuration file is its own
owned file (ADR 0182).
- A version manager contributes its directory variable to the environment, and its loader as code
for each shell it supports.
- A toolchain contributes a `PATH` entry to the environment and nothing to the shell.
What has to be settled: what each contribution is *addressed to*. Section 6 covers that.
## 3. The operator's own lines: the "local override"
Assigning the shell module must lose nothing the machine does today. That has two halves.
**What is common is the module's default, not an override.** The startup file is identical on all four
machines ([01](01-what-the-shell-file-holds-today.md)). A line every machine has is the shell module's
default, or another module's contribution. It is not a local override that a person would keep in step
on every machine by hand. Most of today's file therefore moves into the shell module's block and into
the contributions above. Little of it stays the operator's.
**What is the operator's is everything outside the mesh's block.** The host already works this way:
- the mesh's region is the marked block;
- text outside it is kept byte for byte, and checked unchanged;
- the region is given back when the module goes.
| | option | for | against |
|---|---|---|---|
| O1 | The mesh's block at the **start** of the file; the operator's lines after it | the operator's lines run last and win, which is what an override means; already supported (`at: start`) | a file the operator later rewrites must keep the markers; the host refuses a broken pair rather than guess |
| O2 | A named operator region *inside* a file the mesh writes whole (ADR 0174's wording) | the file is entirely the mesh's except one hole | the opposite of what the host implements; a file a person already owns becomes the mesh's |
| O3 | Only `~/.zshrc.local`, sourced from the block; `~/.zshrc` the mesh's whole | one obvious place | takes over a file the person owns today; ADR 0182 classifies the shell's own file as *written into*, not owned |
**Starting position: O1.** `~/.zshrc.local` keeps working because the operator's own lines source it,
not because the mesh's block does.
The record this effort becomes corrects ADR 0174's description of the kept region as a **progressive
insight**: the decision stands (a node varies a module by settings or by the operator's own lines,
never by an edit), and only its description of which side is marked changes.
**The one-off migration** is a person's act, listed in the module's documentation (ADR 0182):
- remove from today's file every line the block or a contribution now carries;
- keep the rest below the block.
Until a prompt module and the other contributors exist, the lines they will carry stay among the
operator's own. Nothing is lost at any step.
## 4. Order
Order matters only for code. The environment is set before any code runs, because zsh reads
`.zshenv` first. `PATH` entries carry their own position (before or after the system's), which the
environment module orders, not the shell.
| | option | for | against |
|---|---|---|---|
| R1 | Numbers (`10`, `50`, `90`) | familiar | every contributor guesses a number; collisions are silent |
| R2 | **A few named slots**, `first` / `normal` / `last`, with the module name breaking ties | the prompt says `first` and highlighting says `last` because that is what they mean; the composed result is the same bytes every time | three slots may not be enough |
**Starting position: R2.** Inside the shell module's block, the order is:
1. the line sourcing the environment module's file (in `.zshenv`, so it runs for every zsh; the rest
of this list is `.zshrc`);
2. the `first` slot;
3. the shell module's own defaults;
4. the `normal` slot;
5. the `last` slot.
The operator's lines come after the block, as option O1 says.
## 5. Who renders: the controller or the holder's code
There are two different renderings, and E6 lets them be answered differently.
**The environment** is facts rendered into two fixed formats by the one module whose subject they are.
- The environment module receives the gathered contributions (the `contributes` / `receives` shape:
facts in the mesh's own format, rendered by the receiver).
- Its own code writes the POSIX file and the `environment.d` file whenever what it receives changes.
That is ADR 0182's third class, written by the module's own process, owned by the account,
atomically.
- The controller learns no shell and no service manager. The `PATH` rules (prepend or append,
de-duplicate, keep the system's entries) are ordinary code with ordinary tests.
- To settle: what runs that code when the received file changes. The candidates are a host action
that restarts on the received file, or a subscription through the runtime (ADR 0198).
**Shell code** is not facts. It is text in the shell's own syntax, assembled in slot order, which is
what the controller already does for fail2ban jails: sort the pieces and concatenate them into the
holder's region. The controller assembles; it never interprets the code. This keeps the shell module
bundle-free for its files, and keeps the composed result visible in the declaration before a machine
applies it.
## 6. What a contribution is addressed to
Under E6 there are two addressees: the environment and the login shell.
| | option | for | against |
|---|---|---|---|
| A1 | **Seats**: environment facts to `node-environment`, shell code to `login-shell`. Each seat's protocol says what its holder does with what is contributed to it | a contributor depends on a role ("the environment", "the login shell"), never on zsh or on one module; works the same for any holder | `login-shell` today is declared by the zsh module itself (ADR 0126). A second shell module may only claim it, never declare it, and the seat exists only while zsh's definition is registered |
| A2 | Requirements the modules provide (`contributes` keyed by them, as the reverse proxy is) | an existing mechanism | a contributor on a node without the provider fails to resolve, though a toolchain's `PATH` entry with no environment module is merely unwritten |
**Starting position: A1, both seats in the mesh's own seat set** beside the service manager.
- `node-environment` is new, and is the mesh's from the start.
- `login-shell` moves there from the zsh module's definition. A shell is as universal a role as a
service manager, and a protocol that now carries duties (render the shell code contributed to it,
source the environment file) should not depend on one module's registration.
Research 023 (a seat's protocol naming what its holder owns) is the general form of this: the
environment seat would own the two environment files, and the login-shell seat the shell's
startup-file region. The two efforts should not decide it twice.
## Open questions
- Whether a contribution may be conditional on a capability: the workstation-only environment (a
browser, a toolkit theme) is a desktop module's contribution, which arrives only where that module is
assigned. Measured, this may need nothing new.
- What a node without the environment module does with environment contributions: refuse them at
resolve, or leave them unwritten and say so. The position here is to say so; a missing `PATH` entry
is a visible gap, not a broken machine.
- Whether the operator's own variables (the script-library paths in [01](01-what-the-shell-file-holds-today.md))
are the operator's lines below the shell block, or a kept region of the environment module's file.
The first needs nothing new, but reaches only interactive zsh.
- How the prompt module and a plugin module divide the plugins. Packaging decides it as much as
ownership: the plugins come from upstream repositories, not distribution packages, on these machines.
- Whether the `execute` verb should read the interactive file at all once the environment is in
`.zshenv`. The position here is no: a non-interactive login shell plus the environment is what a
command needs, and the prompt's code should not run for it.
- How a contribution reaches a second shell assigned beside the holder, which to-be 38 WP5 names as the
first follow-up record. Under A1 a non-holder renders nothing, so the question becomes whether a
non-holding shell module may render contributions for interactive use.
@@ -0,0 +1,74 @@
---
status: active
initiated: 2026-10-04
touches:
- 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/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
became: []
---
# 026 — The graphical session as modules
## What is investigated
The workstations' graphical session as modules of the mesh, at the same level as the shell
([to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)): a package, files
under the account's home, a seat, and nothing that names a machine. The pieces are:
- the login manager;
- how a session starts and what environment it gets;
- the display server (X today, Wayland as a sibling);
- the window manager (i3, and sway as its Wayland sibling);
- the terminal emulator (xterm);
- the session's companions: bar, compositor, launcher, notifier, lock and idle, clipboard,
wallpaper, theming, fonts.
[To-be 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) names this WP7, and says
each seat begins with a record naming its holders and verbs. [To-be 37](../../03-DESIGN/01-to-be/37-the-operators-machine.md)
§4 leaves one question for the resolver: whether a held seat can gate another's assignment.
## Why
The operator asked for the graphical modules next, at the shell's level, and for one consistent
experience across machines. Since the predecessor retired, nothing manages the workstations'
desktops. Measured in [01](01-what-the-workstations-run.md):
- Two workstations carry one 983-line predecessor module's output, still byte-identical in its core.
- One workstation also carries another machine's hardware fragments.
- One runs a session that predates two fixes, with two notification daemons and two portals.
- The session's environment is a hand-kept second copy of the account's, beside the one the mesh
now writes.
## How it is approached
**Adopting is also improving** (the operator, 2026-10-04). A module is not a copy of what a machine
does today. Making it is the moment to fix what is broken, drop what is dead, choose the better tool
and remove the leftovers. Every module's design lists its improvements over today. **Every module
also serves tools,** many of them, for reading, acting and diagnosing; a module that only places a
package and a file is unfinished. The tools are catalogued in
[026/05](../026-the-graphical-session-as-modules/05-the-tools-each-module-serves.md).
## What it touches
- **The seat table:** up to ten node seats.
- **The resolver:** a seat held on a node gating another module's assignment.
- **The contribution mechanism of ADR 0204:** whether it generalises beyond shells, or whether
tools' own drop-in directories serve.
- **The host's user-scoped units** (mesh-host #72, still open).
- **Settings** for per-machine values (issue 168).
- **ADR 0205's archive** for the two pieces the distribution does not package.
## Documents
- [01 — What the workstations run](01-what-the-workstations-run.md): evidence.
- [02 — The questions and the options](02-the-questions-and-the-options.md)
- [04 — Screensaver, displays and menus](04-screensaver-displays-and-menus.md): the lock and idle module, monitor layouts by the monitors' identity, rofi and dmenu behind one launcher seat, the clipboard, fonts
- [05 — The tools each module serves](05-the-tools-each-module-serves.md): a first catalogue for the modules of 026 and 027
- [03 — What the predecessor taught](03-what-the-predecessor-taught.md): its 128 modules and 3,395 commits, as patterns to keep and failures not to repeat; shared with research 027.
@@ -0,0 +1,141 @@
# 01 — What the workstations run
Measured 2026-10-04 on the two workstations of one installation, read-only: a laptop with a hybrid
GPU and an internal panel, and a desktop with one GPU and two external monitors. Both run the same
predecessor-generated desktop. File equality was checked by checksum across the two machines.
## How a session starts
The chain is the same on both:
1. The login manager (`lemurs`, built from the distribution's user repository, its package now in
the official one) runs its X setup script on a virtual terminal.
2. That script sources the login shell's profile files, then `~/.xprofile`, then the system's
`xinitrc.d` drop-ins, then merges `~/.Xresources`.
3. `~/.xprofile` reuses the systemd user manager's bus, then sources `~/.xinitrc`.
4. `~/.xinitrc` sets up the session and ends with `exec i3`.
The login manager's own window-manager entry (`exec startx`) is never reached. Its configuration
file uses a format two releases old, and an unmerged newer one sits beside it.
**What `~/.xinitrc` does**, in order:
1. Sources the system drop-ins, which import `DISPLAY` and `XAUTHORITY` into the user manager.
2. Starts the keyring and exports its ssh socket.
3. Exports the session's environment:
- `PATH`, with nine entries, one of them a directory that no longer exists;
- toolchain variables;
- `XDG_CONFIG_HOME` and `XDG_DATA_DIRS` (with flatpak);
- five GTK/Qt theme variables;
- the desktop's identity (`XDG_CURRENT_DESKTOP`, `XDG_SESSION_DESKTOP`);
- three of the operator's own variables.
4. Imports an explicit allowlist of ten of those into the user manager and D-Bus activation. It is
never `--all`, because:
5. a predecessor file of **secrets as environment variables** (package-registry and API tokens) is
sourced next.
6. Sets the screensaver and display power timeouts, restores the wallpaper, and starts the lock
watcher in a respawn loop. It is deliberately not a unit, because it needs the login session.
7. `exec i3`.
**The account's environment, as of today, has three sources that disagree:**
- this file, for the session;
- the mesh's `environment.sh`, for shells
([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md));
- `~/.config/environment.d/`, for the user manager. It holds the mesh's `50-mesh.conf`, and a
predecessor file that **sets `PATH` outright** and sorts after it.
## The roles, and what fills them
| role | software | where configured |
|---|---|---|
| login manager | lemurs | `/etc/lemurs/*` (identical on both, and to the predecessor's source) |
| session start and environment | the login manager's X setup, `~/.xprofile`, `~/.xinitrc`, `xinitrc.d`, the D-Bus import, `environment.d` | `~/.xprofile`, `~/.xinitrc`, `~/.config/environment.d/*` |
| display server | Xorg (`xorg-server`, `xinit`, the X apps; vendor drivers per GPU) | **no** `xorg.conf.d`; monitors by `xrandr` scripts |
| monitor layout | `xrandr` scripts (arandr), a hotplug rule on the laptop | `~/.screenlayout/`, a scripts folder, a window-manager fragment |
| window manager | i3 4.25 | `~/.config/i3/config` and `config.d/*`, a reload watcher (user unit) |
| bar | i3bar with i3status-rust | `~/.config/i3status-rust/*`, 14 themes, a bar watchdog (user unit) |
| terminal | xterm (the only terminal installed) | `~/.Xresources.d/xterm`, the window manager's binding, the compositor's opacity rule |
| compositor | picom | `~/.config/picom/picom.conf` |
| launcher and menus | rofi | `~/.config/rofi/*`, launcher, power-menu and theme-picker scripts |
| notifier | dunst (D-Bus activated) | `~/.config/dunst/dunstrc`, `dunstrc.d/*` |
| lock, idle, display power | xss-lock and i3lock-color, `xset` | `~/.xinitrc`, a lock script |
| clipboard | greenclip, xclip | `greenclip.toml` |
| wallpaper | feh | `~/.fehbg` (points into the predecessor's tree) |
| theming | Adwaita dark, qt5ct/qt6ct, the desktop portal (GTK backend pinned) | GTK `settings.ini`, `qt*ct.conf`, `portals.conf`, an appearance script, `.Xresources` cursor |
| fonts | Hack and Meslo Nerd fonts in `~/.local/share/fonts` (not packaged), noto | `~/.Xresources.d/xft` (DPI fixed at 96) |
| keyboard | nothing set; the default layout; vendor keys via triggerhappy on the laptop | window-manager bindings, `/etc/triggerhappy` |
**Packages:** every piece except two is in the distribution's official repositories, and the login
manager now is too. The two exceptions are the lock screen's colour build (`i3lock-color`) and the
clipboard manager (`rofi-greenclip`). The Nerd fonts exist as official packages, but both machines
carry hand-copied files instead.
## Identical, different, and why
**Byte-identical on both machines:**
- the session files: `.xinitrc`, `.xprofile`, `.Xresources` and its drop-ins;
- the i3 main configuration and two of its fragments;
- the bar's top configuration and themes;
- picom, rofi, the GTK and Qt settings, the portal configuration, the login manager.
**Different, by cause:**
| cause | what |
|---|---|
| hardware | the monitor layout script; the bar's battery block; the laptop's power and vendor-key units and udev rules |
| misassignment | the desktop carries the **laptop's** hardware fragments: the vendor-key daemon and its triggers, the backlight rule, the brightness drop-in, a touchpad reset, and the laptop's monitor layouts, in an older version |
| drift | the notifier's position and corner radius; a "temporary" window-manager fragment from a test; the bar watchdog disabled; a second Qt configuration tool; different font builds |
| a stale session | the desktop's session began before two fixes, so it runs two notification daemons and two portals, and its user manager lacks the desktop's identity |
**Dead references:** the window manager starts a polkit agent that is installed on neither machine,
so there is no polkit agent at all. `PATH` names a directory that does not exist.
**Per-machine values inside shared files:**
- the DPI;
- absolute home paths, in the clipboard configuration and the flatpak data directories;
- the laptop's panel name, inside a fragment both machines carry.
## User units the desktop needs
| unit | does | laptop | desktop |
|---|---|---|---|
| reload watcher | reloads the window manager and bar when their files change | on | on |
| bar watchdog | restarts a dead bar | on | off |
| clipboard daemon | from its package | via the window manager | unit **and** window manager |
| vendor power profile, memory guard | laptop power | on | — |
None is managed. Applying them as the account needs the host's user scope
([ADR 0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)),
which is still an open change.
## The predecessor's module
One manifest of 983 lines covers the window manager, bar, launcher, notifier, compositor, lock
screen, session bootstrap, theming and scripts. It:
- has four *flavors*: i3, laptop (i3 plus the monitor wizard and hotplug), desktop (i3 plus
nothing) and a laptop model (laptop plus vendor keys);
- has about **105 theme variables** substituted into templates: border, gaps, fonts, workspace
names, every colour of bar, launcher, notifier and lock screen, compositor opacity, cursor, idle
times, Qt and GTK theme names;
- enables the two user units from an install hook.
Separate modules held the login manager and the display server (one flavor, `xorg`, with a comment
calling `wayland` "the intended sibling"). The shell module held no graphical part.
## Wayland and sway
**Nothing exists.** There is no compositor, no sway configuration, no Wayland session entry, and the
login manager's Wayland directory is empty. What is installed is libraries:
- Wayland itself and the Qt Wayland plugins, which other packages pull in;
- `xwayland`, explicitly installed and required by nothing;
- on the desktop, an orphaned compositor library from another desktop environment, and that
environment's portal backend, pulled in by a game launcher. The portal configuration pins
against it.
Every piece a sway session needs is in the official repositories: the compositor, its lock screen,
a terminal (`foot`), a bar (`waybar`), a notifier (`mako`) and `xwayland`.
@@ -0,0 +1,123 @@
# 02 — The questions and the options
Seven questions. Each has its options and a starting position, which is what this effort tests, not
what it has decided.
## 1. How finely the desktop splits into modules
| | option | for | against |
|---|---|---|---|
| G1 | One desktop module, as the predecessor had | one assignment | flavors again, per machine; ADR 0174 refuses them, and the evidence shows a flavor landing on the wrong machine |
| G2 | **One module per piece of software:** `lemurs`, `xorg`, `i3`, `i3status-rust`, `xterm`, `picom`, `rofi`, `dunst`, `xss-lock` with the lock screen, `greenclip`, `feh`, a theme module, a fonts module | each is what it declares; a machine gets exactly what is assigned; the same split already works for the shell and its plugins | about thirteen assignments per workstation |
| G3 | G2, plus a named **set** the controller assigns as one (for example *the X desktop*) | G2's precision with G1's convenience | a set is a new controller concept |
**Starting position: G2.** Whether a set is worth a record is left until the thirteen assignments
have been done by hand once.
## 2. The seats
Research 018 listed the candidates. ADR 0204 has since put the login shell in the mesh's own set,
because a role with a protocol should not depend on one module's registration. The same reasoning
applies here:
| seat | holders | protocol, first verbs |
|---|---|---|
| `node-login-manager` | lemurs, greetd | which sessions it offers, the default session |
| `node-display-server` | xorg, sway | `displays`, `layout` |
| `node-display-session` | i3, sway | `reload`, `workspaces`, `windows` |
| `node-terminal-emulator` | xterm, foot, alacritty | which terminal `$TERMINAL` names; `open` |
| `node-bar`, `node-compositor`, `node-launcher`, `node-notifier`, `node-lock-screen`, `node-clipboard` | the pieces above, and their Wayland counterparts | one verb or none each, until a use asks for one |
**A compositor that is its own server holds two seats.** Sway is both the display server and the
display session. A module may claim several seats, so this needs nothing new.
**Starting position:** the first four seats are in the mesh's own set. The companion seats are
added only as each holder is written; for those, a module without a seat is acceptable at first.
## 3. One module requiring another seat to be held
i3 needs an X server held on its node, and sway needs nothing below it. A terminal needs a session.
To-be 37 left open how that is said.
| | option | for | against |
|---|---|---|---|
| R1 | A seat **delivers a provision** (`x11-display`, `wayland-display`) and a module requires it at node scope. The seat table has a `delivers` field already, and requirements already resolve | existing machinery; the refusal names the seat and its possible holders, which design 27 already lists | a node-scoped requirement that never crosses machines has to be stated as such |
| R2 | A new field, *needs the seat X held* | reads plainly | a second way to say what R1 says |
| R3 | Nothing; assign carefully | — | the mistake the evidence shows (a laptop's fragments on a desktop) is exactly an unchecked assignment |
**Starting position: R1.** `xorg` and `sway` each deliver what they serve. `i3`, `picom` and `xss-lock`
require `x11-display`. `foot` requires a Wayland display, and xterm requires an X one, which a Wayland
session gives through `xwayland`.
## 4. Who starts the session, and with what environment
Today `~/.xinitrc` is a hand-kept second environment and the session's whole start script.
| | option | for | against |
|---|---|---|---|
| S1 | The display server's module writes `~/.xinitrc` **into**: a mesh block at the start that sources the account's environment (`environment.sh`), merges the X resources, and runs the session's contributed start lines. The session holder's module contributes its `exec` line. The operator's lines stay after the block | one environment for shells, the session and the user manager; nothing to keep in step | the order inside `.xinitrc` becomes the slot order of a contribution (question 5) |
| S2 | The login manager's module owns the session script under `/etc` | system scope; no home file | the environment is the account's, and the script is the same for every account |
| S3 | Leave `.xinitrc` the operator's | nothing to build | the third environment stays |
**Starting position: S1.**
- The desktop's identity (`XDG_CURRENT_DESKTOP`) and the theme variables become **environment
contributions** (ADR 0203) from `i3` and from the theme module. They then also reach the user
manager through `environment.d`, which replaces most of today's allowlist import.
- The secrets file stays out of the environment until research 027 settles how a secret reaches an
account.
## 5. How other modules contribute to a holder's file
The terminal's settings are X resources. A bar, a launcher binding and a hardware module's key
bindings are window-manager configuration. Autostarts are the session's. ADR 0204 built slot
contributions for shells only.
| | option | for | against |
|---|---|---|---|
| C1 | **The tool's own drop-in directory**, where it has one: i3's `include`, dunst's `dunstrc.d`, X resources' `#include`, XDG autostart entries, `environment.d`. Each contributor owns its own file there | no mesh change; the tools already read these directories; unassigning removes the file | each contributor names a path in another tool's directory (ADR 0204 rejected this for shells, where no drop-in convention exists); ordering is by file name |
| C2 | **ADR 0204's mechanism generalised:** `contributes` text *for a format* (`zsh`, `xresources`, `i3`, `xinitrc`) in a slot, placed by the holder's placeholder | one mechanism, checked by the controller, order declared | every format must be named in the controller; a bigger change to ADR 0204 |
| C3 | C1 where the tool has a drop-in convention, C2 where it does not (`.xinitrc`, `.Xresources` order) | uses each tool's own grain | two mechanisms to learn |
**Starting position: C3**, with the boundary drawn by the tools. A tool that reads a directory gets
drop-ins. A file without one gets slots. This means amending ADR 0204's "shell" to "a format", which
is a progressive extension rather than a reversal.
## 6. What varies per machine
| what | today | option |
|---|---|---|
| monitor layout | per-machine `xrandr` scripts, monitor names baked in | a **setting** of `xorg` (issue 168), and a `layout` verb of the display server seat |
| DPI, fonts' size | fixed in an X resource | a setting |
| battery block, vendor keys, brightness, touchpad | a laptop model's flavor | **a hardware module** per machine model, contributing its window-manager fragment, bar block and udev rules. The desktop simply is not assigned it |
| theme (the 105 variables) | template substitution | settings of each tool's module, after issue 168 closes (ADR 0174). Until then each module carries today's values as its default |
**Starting position:**
- Hardware modules for what follows the machine.
- Defaults now, settings after issue 168, for what the operator varies.
- The monitor layout waits for settings. Until then it is an operator-owned script the display
server's block calls if present.
## 7. Wayland and sway
Nothing of a Wayland session exists, and every piece is officially packaged. "Wayland" is a protocol,
not a piece of software, so it has no module of its own. Its parts are `sway` (server and session),
`swaylock`, `foot`, `waybar`, `mako`, and `xwayland` for X clients.
**Starting position:**
- The seats and the requirements (questions 2 and 3) are designed so that sway fits from the first
day.
- The X stack is built first, because it is what runs.
- `sway` and its companions are written after that, and proven on one workstation as a second
session the login manager offers beside i3. That lets the operator try it without losing the
working desktop.
## Prerequisites this effort cannot remove
- **User-scoped units** (mesh-host #72) for the reload watcher and the bar watchdog.
- **Settings** (issue 168) for monitors and theme values.
- **The two packages not in the official repositories:** the lock screen's colour build and the
clipboard manager. Each is ADR 0205's case, a pinned archive, or a choice of an official
alternative (`i3lock` without colours; `clipmenu`/`cliphist`).
@@ -0,0 +1,51 @@
# 03 — What the predecessor taught
A study on 2026-10-04 of the retired predecessor:
- its 128 module manifests, their hooks, its installer and its sync engine;
- 3,395 commits of history;
- what it left on four machines.
This document holds what bears on the graphical session and on the system layer
([research 027](../027-the-system-layer-as-modules/00-overview.md)). The evidence is in the
predecessor's history. A commit is cited here by what it fixed, not by its hash, because the
repository is private.
## Keep: what worked
| pattern | where it shows | in the mesh |
|---|---|---|
| ownership marked inside the file: inside the markers is reconciled, outside is kept verbatim | a block marker in a shared file, after an engine that rewrote whole files | kept regions (ADR 0174, ADR 0204) |
| two writers get two files and an `include`, the include first | the ssh client's configuration, after two writers fought over one file | ADR 0203's two files; research 026 C1 |
| one writer per file, one authority per action | only the reload watcher restarts the window manager, after three mechanisms each did | ADR 0182 |
| refuse to write when the source of truth is unreadable; never empty a block because a query found nothing | a block of names was emptied by a failed query | — keep |
| an unresolved template variable fails the install | a literal unfilled path was installed green | [issue 231](../../04-ISSUES/231-a-misspelled-placeholder-is-written-out-as-text/00-report.md): the mesh still has this gap |
| prune only what you can prove you placed | stale files from earlier deliveries | ADR 0189 |
| ensuring never rotates a credential | a silent rotation caused a retry storm, a ban of the shared address and a lost registry | ADR 0114 |
| vendor only the files you use; never clone and link | three files instead of 77 MB | ADR 0205 |
| copy, never symlink | a recursive delete followed a link, and every reinstall failed | ADR 0012 |
| verification says what it did not check | a verifier said *clean* while the secret was still on disk | — keep |
| alert once per condition | 411 alerts hid a 28-hour outage | ADR 0090 |
## Do not repeat
| failure | what it did | the mesh instead | where the mesh is still exposed |
|---|---|---|---|
| **Flavors** | variant files and packages per machine type: a gate dropped, the first-seen variant won, packages never installed, the verifier ignored the gate. On the day of the study a desktop carried a laptop model's fragments | one module per piece, assignment per machine (ADR 0174, research 026 §1) | a setting that switches which whole file is rendered is a flavor under another name |
| **The freeze** | existing values outranked new defaults; templated files were rendered once (*copy if absent*) | files are generated (ADR 0011) | a created-once file (ADR 0087) is a deliberate freeze, and a push must say *kept* |
| **Adopting drift** | a *merge* strategy made a local edit the record forever; switching strategies clobbered a person's model choice | nothing is read back (ADR 0174) | an edit outside a kept region is overwritten **silently**. The predecessor's *why is this back* loop: the push should name what it overwrote |
| **Environment templating** | `${VAR}` matched any name; unresolved names stayed literal; comments and destination paths were interpolated | namespaced placeholders; `$` forbidden in contributed values (ADR 0203) | issue 231 |
| **Hooks with privilege** | install hooks ran `sudo`, `chsh`, `systemctl`, `git clone` and `curl`, and swallowed failures into a warning | the `user` shape, the service shape, archives (ADR 0176, 0177, 0205) | the agent module writes under `/etc` from its own tool through `sudo` (no keep-original, no give-back); the prompt's helper downloads itself unpinned; that the operator escalates without a prompt is assumed by three modules and declared by none (research 027) |
| **Secrets in environment files** | `.env` files left world-readable; the decryption key beside what it decrypts; a deleted secret stayed in the file, so rotation was a no-op | the vault (ADR 0113, 0114) | a predecessor file of secrets is still sourced into the graphical session on two machines (research 027 Q2) |
| **Symlinks into a home** | a system file linked into a person's home | ADR 0012 | on the control machine, a fail2ban action file is still a predecessor link into its home tree. Deleting that tree would silently break the repeat-offender jail. The mesh's fail2ban module must own it as a file first |
| **Green while broken** | a recorded version frozen for four months; a verifier passing what it skipped | ADR 0134, 0145, 0184 | issue 230: a plan waiting for ever reads as healthy |
## What it means here
- **Research 026:** the desktop's 88 flavor-gated files and 92 theme variables are the flavor and
templating failures in one module. Question 1 (one module per piece) and question 6 (hardware
modules, settings later) are the answer, and nothing in the new modules may switch whole files on a
setting.
- **Research 027:** the hooks that installed the AUR helper, enabled the login manager and changed
shells are what the `package`, `service` and `user` shapes replace. Every remaining `sudo` in a module's
own code is a debt to be named, starting with the agent module.
@@ -0,0 +1,129 @@
# 04 — Screensaver, displays and menus
Three areas the operator named on 2026-10-04, as their own modules. Each sharpens a row of
[01](01-what-the-workstations-run.md) and a question of [02](02-the-questions-and-the-options.md).
## The screensaver: idle, lock and display power
**Measured on both workstations:**
- **Idle and lock** are three things wired by hand in the session's start script:
- the X screensaver timeout (`xset s 1800`);
- the display power timeouts (`xset dpms`);
- `xss-lock` running the colour build of `i3lock` through a wrapper, in a respawn loop.
- **A second screensaver,** xscreensaver, is installed and deliberately not started. Earlier it
overrode the display power settings with its own, and locked nothing. Its configuration file is
still in the home.
- **The lock screen's 20-odd colours and formats** were predecessor theme variables.
- **The colour build is not in the official repositories** (research 026/01).
**Starting position:**
- **One module for the lock screen,** holding `node-lock-screen`: the locker and its wrapper as the
module's own files, the screensaver and display power timeouts, and `xss-lock`.
- The timeouts and colours are its defaults, and settings later (issue 168).
- The colour build ships as ADR 0205's pinned archive, or the module uses the official `i3lock`.
That is the operator's choice, and the colours are the only difference.
- xscreensaver is not a module; its package and file are removed.
- `xss-lock` needs the logind session, so it stays a session-start line contributed into
`.xinitrc`'s block (question 4), not a unit.
## Monitor layout (xrandr)
**Measured:**
- Each workstation has a layout script generated by `arandr`, with the monitor names baked in. One
workstation also has several layouts for named places, a hotplug rule and a wizard.
- **The desktop carried the laptop's layout scripts.**
- No `xorg.conf.d`, and no layout tool beyond the scripts.
**Starting position: `autorandr`** (official repositories) inside the display server's module.
- `autorandr` saves a layout as a profile **keyed by the connected monitors' identities** (their EDID)
and applies the matching one at login and on hotplug.
- Profiles therefore need no machine's name. A profile can be shared mesh-wide and simply never
matches on a machine without those monitors. That is exactly the "say it by what is there, never by
a name" rule (ADR 0112).
- The profiles are the operator's data, saved by the tool itself, so they are *found* (ADR 0182). A
`layout` verb on `node-display-server` lists, saves and applies them.
- The arandr scripts and the hotplug rule retire once a profile exists for each.
## Menus: rofi and dmenu
**Measured:**
- rofi is the launcher, the power menu, the theme picker and the clipboard menu.
- The operator's scripts call `rofi -dmenu` in four places and **plain `dmenu` in two. dmenu is
installed on neither workstation, so those two fail.**
**Starting position:**
- **`rofi` holds `node-launcher`**, and the seat's protocol includes a **dmenu-compatible command**:
read choices on standard input, print the chosen one. Scripts call that command, not a program by
name.
- **`dmenu` is a module of its own** (official repositories), able to hold the same seat on a machine
that wants it, for instance a Wayland session where `wofi` or `fuzzel` would hold it instead.
- The rofi module carries its theme files, and the menus that belong to other modules arrive as those
modules' scripts:
- power menu → the session;
- clipboard menu → the clipboard module;
- theme picker → settings, once issue 168 closes.
## The clipboard: xclip and greenclip
**Measured:**
- **greenclip** keeps the clipboard's history, and rofi shows it on a key binding.
- **greenclip is not in the official repositories.**
- It is started two ways: the window manager's configuration starts it on both workstations, and on
one a user unit is enabled as well.
- Its configuration names an absolute home path.
- **xclip** (official) is the command-line clipboard the operator's scripts use.
**Starting position:**
- **`xclip` is a module of its own,** a package and nothing else. It is the tool scripts depend on,
and a module that needs it requires it.
- **The clipboard manager holds `node-clipboard`:** its daemon, started once by the session (a session
contribution, or a user unit once user-scoped units ship, never both), its configuration with no
absolute path, and its menu binding contributed to the window manager.
- **Which manager holds it is the operator's choice:**
- greenclip, as today, shipped under ADR 0205;
- or `clipmenu` (official), which feeds the same dmenu-compatible command as the launcher seat above,
and needs no archive.
- **On Wayland** the same seat is held by `cliphist` with `wl-clipboard`, both official.
## Fonts
**Measured:**
- The fonts the desktop uses are **hand-copied files** in the account's font directory, not packages:
- a Nerd font for the window manager, the bar and the terminal;
- a second one for the prompt;
- on one workstation, the same four files twice, once under URL-encoded names;
- on the other, a different build of the same font and three more copied from a theme's repository.
- The system's default monospace is a different font (`Noto Sans Mono`), so anything that asks for
`monospace` gets another face than the terminal.
- The DPI is fixed in an X resource.
- **Every Nerd font in use is in the official repositories** (Hack, Meslo, Iosevka, JetBrains Mono).
**Decided** (the operator left the choice open, except that it must not be today's Hack):
| role | face | why |
|---|---|---|
| monospace: terminal, window manager, bar, launcher, prompt | **JetBrains Mono Nerd Font** | built for long reading in a terminal, unambiguous `0O1lI`, optional ligatures; a version-3 Nerd font, so every icon the prompt and bar use is present |
| interface: GTK, Qt, notifications | **Inter** | designed for screens, clear at small sizes |
| icons missing from any face | Nerd Fonts Symbols | a fallback, so a font without icons still shows them |
| emoji | Noto Color Emoji | |
| serif and every other script | Noto | |
All five are official packages.
**Starting position:**
- **A `fonts` module:** those packages, and a fontconfig file it owns that maps `monospace`, `sans-serif`,
`serif` and the emoji and symbol fallbacks to the chosen faces, so every program agrees.
- The terminal, bar, launcher and prompt modules name the family, not a file.
- The DPI becomes the display server's setting (issue 168).
- The copied files are removed by the operator once the packages are in (ADR 0182).
- Fonts are not a seat: several coexist. The module owns the one place where *the* default is said.
@@ -0,0 +1,77 @@
# 05 — The tools each module serves
A first catalogue for the modules of research 026 and 027, as the operator asked: "all kinds of useful
tools for all these modules". Each tool is served by the node's runtime (ADR 0175), on the machine the
module runs on. Through discovery (ADR 0195) it is reachable from any machine as
`<machine>/<module>.<tool>`, or as `<machine>/<seat>.<verb>` where a seat defines it.
**Conventions:**
- **(r)** reads.
- **(a)** acts on the machine, escalating where it must, as the packet filter does (to-be 38 WP4).
- **(d)** is a desktop act that needs the operator's session.
- A tool that changes something a module declares says so in its answer: the next push restores the
declaration.
- Every tool answers structured data, not prose (issue 229).
- **Seat verbs** (marked *seat*) are the protocol every holder of that seat serves. The rest are the
module's own.
## The graphical session (026)
| module | tools |
|---|---|
| `xorg` (*node-display-server*) | *seat* `displays` (r: outputs, modes, rates, connected monitors with their identity) · *seat* `layout` (r/a: list, save, apply an autorandr profile) · `set-mode` (a: one output's resolution, rate, rotation, scale) · `primary` (a) · `dpi` (r/a) · `input-devices` (r) · `input-set` (a: touchpad tap, natural scroll, pointer speed) · `keyboard` (r/a: layout and options) · `screenshot` (d: one screen or all, as a file) · `x-log` (r: the server's errors since start) |
| `i3` (*node-display-session*) | *seat* `reload` (a) · *seat* `workspaces` (r) · *seat* `windows` (r: tree with classes, titles, workspaces) · `focus` (d: window or workspace) · `move` (d: window to workspace or output) · `layout-save` / `layout-restore` (d: a workspace's arrangement) · `exec` (d: start a program in the session) · `kill` (d) · `bindings` (r: every key binding and what it runs) · `config-check` (r: validate the composed configuration before a reload) · `marks` (r) · `scratchpad` (d) |
| `sway` (*node-display-server*, *node-display-session*) | the same seat verbs over Wayland, plus `outputs` (r) and `idle-inhibitors` (r) |
| `lemurs` (*node-login-manager*) | *seat* `sessions` (r: what the login screen offers) · *seat* `default-session` (r/a) · `logins` (r: who logged in when, from the journal) |
| `xterm` (*node-terminal-emulator*) | *seat* `open` (d: a terminal, optionally running a command, in a directory) · `font` (r/a: face and size) · `colours` (r) |
| `i3status-rust` (*node-bar*) | *seat* `reload` (a) · `blocks` (r: what the bar shows and each block's current value) · `block-run` (r: run one block once and answer its output) · `themes` (r) |
| `picom` (*node-compositor*) | *seat* `restart` (a) · `rules` (r: opacity, shadow and blur rules in force) · `window-opacity` (d) · `toggle` (d: compositing off and on, for a game or a test) |
| `rofi` (*node-launcher*) | *seat* `menu` (d: show a list, answer the chosen line: the dmenu-compatible command as a tool) · `applications` (r: the desktop entries it would offer) · `themes` (r) · `run` (d) |
| `dmenu` (*node-launcher*) | *seat* `menu` (d) |
| `dunst` (*node-notifier*) | *seat* `send` (d: title, body, urgency, actions) · *seat* `history` (r) · `pause` / `resume` (d: do not disturb) · `close-all` (d) · `rules` (r) · `count` (r: shown, waiting, history) |
| lock module (*node-lock-screen*) | *seat* `lock` (d) · `idle` (r/a: screensaver and display power timeouts) · `inhibit` (d: keep the screen on for a while) · `locked` (r: is the session locked now, and since when) |
| clipboard manager (*node-clipboard*) | *seat* `history` (r: entries, newest first, length-limited) · *seat* `copy` (d: put text on the clipboard) · `paste` (r: what the clipboard holds now) · `clear` (d) · `delete` (d: one entry) |
| `xclip` | `copy` (d) · `paste` (r): the plain clipboard without a manager |
| `feh` (wallpaper) | `set` (d: an image, per output) · `current` (r) |
| `fonts` | `families` (r: installed faces) · `match` (r: what `monospace`, `sans-serif` and `emoji` resolve to) · `glyph` (r: which installed font has a given character) · `cache-rebuild` (a) |
| theme module | `appearance` (r/a: dark or light, for GTK, Qt and the portal at once) · `cursor` (r/a) · `icons` (r) · `portal-check` (r: which portal backend answers which interface) |
| `gnome-keyring` (*node-secret-service*) | *seat* `unlocked` (r) · `lock` (d) · `collections` (r: names and item counts, never secrets) · `ssh-keys` (r: what the agent holds, by fingerprint) |
| desktop hardware module (laptop) | `brightness` (r/a: panel and keyboard) · `battery` (r: charge, health, cycles, limit) · `charge-limit` (r/a) · `gpu-mode` (r/a: integrated, hybrid, discrete) · *seat* `profile` (r/a: quiet, balanced, performance) · `thermals` (r: temperatures and fan speeds) · `power-draw` (r) |
## The system and the account (027)
| module | tools |
|---|---|
| `docker` (*node-container-runtime*, ADR 0166) | *seat* `list`, `inspect`, `logs`, `stats`, `start`, `stop`, `restart` (r/a) · `images` (r: with size and which container uses each) · `prune` (a: dangling images, stopped containers not held by the mesh, build cache, with a dry run first) · `disk-usage` (r) · `networks` (r) · `volumes` (r: with what mounts each and whether the mesh holds it) · `events` (r: the last hour) · `daemon-config` (r) |
| `docker-compose` | `projects` (r: compose projects running and where their files are) · `up` / `down` / `restart` (a: one project, by directory) · `logs` (r) · `ps` (r) |
| `sudo` | `rules` (r: what the account may run, without a prompt and with one) · `check` (r: does the escalation the mesh relies on work here) |
| `pacman` | `search` (r) · `installed` (r: with version and explicitly or as a dependency) · `info` (r) · `owns` (r: which package owns a path) · `files` (r) · `updates` (r: what an upgrade would change) · `upgrade` (a: with the news first) · `orphans` (r) · `remove-orphans` (a) · `cache` (r/a: size, clean to the last N versions) · `history` (r: installs and upgrades from the log) · `mirrors` (r/a: rank and refresh) · `news` (r: distribution news since the last upgrade) |
| AUR (package repository, 027 question 1) | `search` (r) · `build` (a: on the build machine, into the mesh's repository) · `outdated` (r) · `published` (r) |
| `snapd`, `flatpak` | `list` (r) · `install` / `remove` (a) · `update` (a) · `runtimes` (r) · `disk-usage` (r) |
| `time-sync` | `status` (r: synchronised, offset, server) · `servers` (r) · `sync-now` (a) |
| `localization` | `get` (r: locale, time zone, keymap) · `time-zone` (r/a) · `locales` (r) |
| `kernel` | `running` (r: version, command line, uptime) · `installed` (r) · `modules` (r: loaded, with what uses them) · `reboot-needed` (r: a newer kernel or library than the one running) · `microcode` (r) · `boot-entries` (r) · `initramfs-rebuild` (a) · `dmesg` (r: errors since boot) |
| `logrotate` | `status` (r: last rotation per log) · `force` (a: one configuration) · `big-logs` (r: the largest logs on the machine) |
| `avahi` | `browse` (r: services on the local network) · `resolve` (r) |
| `cups` | `printers` (r) · `queue` (r) · `cancel` (a) · `print` (a: a file to a printer) · `default` (r/a) |
| `bluetooth` | `devices` (r: paired, connected, battery where reported) · `connect` / `disconnect` (a) · `scan` (r) · `power` (r/a) |
| `ssh-client` (owns `~/.ssh`) | `hosts` (r: every `Host` and where it came from: the mesh, a module, the operator) · `check` (r: modes, keys without a passphrase, keys unused for a year, stale `known_hosts` entries) · `authorized` (r: who may log in, by fingerprint and comment) · `revoke` (a: one authorized key, into the operator's region) · `known-host` (r/a: verify, refresh one host's key) · `test` (r: can this machine reach a host and authenticate, batch mode) |
| `sshd` | `sessions` (r: who is logged in, from where) · `config-effective` (r: `sshd -T`) · `failed-logins` (r: since a time, with fail2ban's verdicts) |
| scripts modules | `list` (r: each script with its one-line description) · `run` (a: one script by name with arguments, as the account, bounded like `execute`) · `which` (r: which module ships a command) |
| `node-env` (*node-environment*) | `show` (r: every variable and `PATH` entry with the module that contributed it) · `diff` (r: what a shell actually has versus what the mesh composed) |
| `zsh` (*node-login-shell*) | *seat* `execute` · `zsh_config` (r) · `history-search` (r: the account's history, by pattern) · `functions` (r: aliases and functions in force, with where each came from) · `startup-time` (r: how long an interactive shell takes to start, per slot) |
| `memory-pressure` | `status` (r: memory, swap, compressed swap ratio, pressure stall) · `top` (r: the largest processes) · `oom-history` (r: what was killed, when) |
| `zfs` | `pools` (r: health, capacity, fragmentation) · `datasets` (r) · `snapshots` (r/a: list, create, destroy by name) · `scrub` (r/a: status, start) · `errors` (r) · `arc` (r: cache statistics) |
| `nfs-server`, `samba` | `exports` / `shares` (r) · `clients` (r: who has it mounted now) · `reload` (a) |
| `nfs-client`, `smb-client` | `mounts` (r: each share, mounted or not, and since when) · `mount` / `unmount` (a) · `test` (r: is the server reachable, is the export offered) |
| hosts-file holder (*node-hosts-file*, ADR 0199) | *seat* `entries`, `add`, `remove` |
| `vnstat`, `lm_sensors` | `traffic` (r: per interface, day, month) · `sensors` (r) |
| mail consumer (future effort) | `accounts` (r) · `search` (r) · `unread` (r) · `read` (r: one message) · `mark` (a) · `send` (a) |
## What this catalogue is for
It is a starting list, not a contract. A tool becomes a contract only when it is a seat's verb, and
each seat's verbs are decided in that seat's record (ADR 0132). A module's own tools can grow freely.
Every row above is a tool the operator would otherwise run by hand over ssh. That is the measure of
whether one is worth writing.
@@ -0,0 +1,64 @@
---
status: active
initiated: 2026-10-04
touches:
- 02-DECISIONS/0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md
- 02-DECISIONS/0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
became: []
---
# 027 — The system layer as modules
## What is investigated
What runs on the machines below the operator's home and outside the mesh's own services, and which
of it should be modules. That covers:
- the container runtime and its tools;
- privilege (sudo);
- the package manager and the software it cannot install;
- time, locale, the kernel and boot;
- log rotation;
- the machine-specific daemons the workstations and servers carry: printing, bluetooth, VPN
clients, virtualisation, storage, sharing.
## Why
The operator asked for the system level beside the graphical session. In particular:
- a `docker` module (decided in principle by the proposed ADRs 0165 and 0166, never built);
- a `docker-compose` module for development work, assigned **only to the two workstations**.
Measured in [01](01-what-the-machines-run.md): on four machines, almost nothing at this level is
owned by a module. The pieces differ by machine for no recorded reason. Three findings are security
matters on their own.
## How it is approached
**Adopting is also improving** (the operator, 2026-10-04). A module is not a copy of what a machine
does today. Making it is the moment to fix what is broken, drop what is dead, choose the better tool
and remove the leftovers. Every module's design lists its improvements over today. **Every module
also serves tools,** many of them, for reading, acting and diagnosing; a module that only places a
package and a file is unfinished. The tools are catalogued in
[026/05](../026-the-graphical-session-as-modules/05-the-tools-each-module-serves.md).
## What it touches
- **The container runtime seat** (ADRs 0165 and 0166, both proposed).
- **The host's `package` shape**, which installs from the distribution's official repositories only,
while the workstations carry 67 and 114 packages from elsewhere.
- **How a secret reaches the account's environment.** ADR 0203 forbids it in the contributed
environment, but a predecessor file supplies such secrets today.
- **The facts the mesh assumes and never declares,** above all that the operator account escalates
without a prompt.
## Documents
- [01 — What the machines run](01-what-the-machines-run.md): evidence.
- [02 — Candidates and questions](02-candidates-and-questions.md)
- [03 — The account's own tools](03-the-accounts-own-tools.md): `~/.ssh` as one module's, scripts on every machine, the keyring, the laptop's power management, mail as events
- The predecessor's lessons, shared with research 026: [026/03](../026-the-graphical-session-as-modules/03-what-the-predecessor-taught.md)
@@ -0,0 +1,103 @@
# 01 — What the machines run
Measured 2026-10-04 on four machines, read-only, including the host's own record of what it applied:
two servers (the anchor and a home server) and two workstations (a laptop and a desktop). "Owned"
means a module the mesh assigns declares it.
## The container runtime
| | anchor | home server | laptop | desktop |
|---|---|---|---|---|
| docker | 29.8.2 | 29.8.2 | 29.7.2 | 29.7.2 |
| compose | 5.5.1 | 5.6.0 | 5.5.0 | 5.5.0 |
| buildx | 0.37.2 | — | — | — |
| podman | — | 6.1.3 | 6.1.0 | 6.1.0 |
| `docker.socket` | disabled | enabled | enabled | enabled |
| `containerd.service` | disabled | disabled | disabled | **enabled** |
| `daemon.json` beyond the shared keys | direct routing, two more insecure registries | log rotation (100 MB × 10) | — | — |
| docker group | operator, **a CI user** | operator | operator | operator |
**Ownership:**
- The `docker` package is owned on one machine only, by the installer's bootstrap, not by a module.
- `docker.service` is declared indirectly, by the name resolver and the private-network modules,
which each merge their own keys into `daemon.json`.
- Nothing owns the socket, containerd, compose, buildx or the group.
**Compose in use:**
- On the servers, no running container belongs to a compose project. Their compose files are
pre-mesh trees under the operator's and root's homes, plus a dangling enabled unit for one of them.
- On the workstations, compose runs development stacks, and pre-mesh service trees sit under a
top-level directory.
The mesh marks its own containers with a host label. On the workstations, a handful of unlabelled
development and test containers run beside its build agent.
## Privilege
- The operator account escalates **without a prompt on all four machines**. The mesh relies on this,
but it is set by hand in `/etc/sudoers` (a `wheel` rule on two machines, the account named on
two), and nothing declares it.
- On the anchor, a **CI user from the predecessor** keeps passwordless sudo and docker membership,
and a predecessor drop-in in `sudoers.d` survives.
- On the desktop, the operator account is also in the **`root` group**.
## The package manager
- `pacman.conf` is stock except on one server (parallel downloads).
- The mirror list was generated once by a tool that is no longer installed. On the anchor, it is the
hosting provider's single mirror.
- An AUR helper is installed everywhere.
- **Packages from outside the official repositories:** 2 on the anchor, 21 on the home server,
67 on the laptop, 114 on the desktop. They include:
- the agent CLI, which a catalogue module declares as a package and the host cannot install;
- a VPN client;
- a remote-access client;
- printer drivers;
- GPU tools;
- a kernel module built from source (DKMS) for a storage filesystem;
- a snap daemon.
## Time, locale, kernel, boot
| | anchor | home server | laptop | desktop |
|---|---|---|---|---|
| time zone, keymap | **another zone**, a non-US console keymap | local zone, unset | local zone, unset | local zone, unset |
| time sync | timesyncd plus a provider drop-in | timesyncd | timesyncd | **ntpd**, timesyncd disabled |
| bootloader | grub (BIOS) | systemd-boot **and** grub | systemd-boot | systemd-boot **and** grub |
| kernels | one | two, plus a DKMS filesystem module | one | one, plus a DKMS controller driver |
| microcode | **none** | yes | yes | **none** |
| swap | RAID partition | partition | zram, a file and a partition | partition |
| log rotation timer | not found | enabled | not found | not found |
## Daemons and services no module owns
- **All four:** avahi.
- **Workstations:**
- a VPN client daemon (both);
- virtualisation (incus) with a hand-made unit that inserts container-runtime firewall rules (both);
- printing and bluetooth;
- GPU and power tuning per model;
- a remote-access daemon (laptop);
- snap and flatpak (desktop);
- the local model server, run from a hand-written unit although a catalogue module for it exists
(desktop);
- Samba sharing and a network filesystem mount from the home server (desktop). A second mount is
failing, and its **credential is written in clear in `/etc/fstab`**.
- **Servers:**
- a storage pool (about 167 TB) with its import, mount and scrub units, an NFS server and Samba
sharing (home server);
- traffic and sensor monitoring (home server);
- a DHCP client daemon the catalogue has a module for but does not assign there (home server);
- cron, an entropy daemon, and the **legacy `iptables` services**, which run beside the mesh's own
filter (anchor).
- **Not found anywhere:** a backup agent, a monitoring agent, a second VPN mesh.
## What is plain debris
- Dangling enabled-unit links on three machines.
- Predecessor blocks in `/etc/hosts` on both servers.
- The CI user, and the predecessor sudoers drop-in, on the anchor.
- Pre-mesh compose trees on the anchor, the home server and the desktop.
- Unlabelled test containers on the workstations.
@@ -0,0 +1,128 @@
# 02 — Candidates and questions
## Decided by the operator on 2026-10-04
- **`docker`** holds the container runtime seat on every machine, as ADRs 0165 and 0166 propose. Those
records are promoted from proposed when it is built.
- **`docker-compose` is a module of its own,** the distribution's package and nothing else. It is
assigned **only to the two workstations**, for development work. The servers run nothing through
compose.
Later the same day, on the candidates below:
- **Yes, all of them:** `docker`, `docker-compose`, `sudo`, `pacman`, an AUR helper (question 1),
`time-sync`, `kernel` (with boot and microcode), `logrotate`, `avahi`, `cups` with the printer's
driver, and every server-only candidate.
- **Locale, time zone and keymap are one module, `localization`.**
- **`snapd` and `flatpak`** are modules, on the two workstations only.
- **`incus` is the lab's,** whose module depends on it. It is not a module of its own beside the lab.
- **The agent's and the local model server's modules are still being developed,** and are not
assigned until they are.
- **The predecessor's CI user is retired.** It was removed from the anchor the same day, with its
sudoers line, its docker membership and a dangling unit link; the backup is on the machine.
## Candidate modules
**On every machine:**
| module | owns | first reason |
|---|---|---|
| `docker` | the packages (runtime, containerd), the service and socket, `daemon.json`'s base keys (live restore, log rotation), the docker group's members | four machines, four configurations, one owner on one |
| `sudo` | the operator account's escalation as a drop-in, declared | the mesh's tools rely on it (to-be 38 WP4) and nothing states it |
| `pacman` | `pacman.conf`'s few keys, the mirror list and its refresher, cache cleaning | mirrors generated once and never again |
| `time-sync` | timesyncd and its drop-ins | two daemons across four machines |
| `localization` | locale, time zone, console keymap (one module, the operator's choice) | one machine differs, with no record why |
| `kernel` | the kernel packages, microcode, initramfs presets | two machines without microcode |
| `logrotate` | the timer and the base configuration | rotation runs on one machine of four |
| `avahi` | the daemon and name-service switch entry | on all four, owned by none |
**On the workstations only:**
- `docker-compose`;
- `lemurs`, the login manager (research 026);
- a VPN client module;
- `incus` with its forward unit (the lab module declares the package on one workstation only);
- `cups` with the printer's driver;
- `bluetooth`;
- per-model **hardware** modules: GPU, power, vendor keys, brightness. These are the same modules
research 026 needs for the desktop's fragments.
**On the servers only:**
- `zfs` with its scrub timer, and the long-term kernel it builds against;
- `nfs-server`;
- `samba`;
- `vnstat`, `lm_sensors`.
`cron` on the anchor serves one stock file and can go. So can the entropy daemon on a modern
kernel.
**Retire, not model:** the legacy `iptables` services on the anchor. They duplicate the mesh's
filter, which is ADR 0100's ground.
## Questions this effort has to answer
1. **Software outside the official repositories.** The host's `package` shape installs from the
official repositories only. A catalogue module already declares an AUR package (the agent CLI),
which no machine could install, and the workstations carry 181 such packages between them.
| | option | for | against |
|---|---|---|---|
| P1 | ADR 0205's pinned vendored archive, per piece | exists | wrong for packages that build native code or kernel modules |
| P2 | **The build machine builds AUR packages into a package repository the mesh serves** from its artifact store. The host then installs them as packages, signed | one shape for every package; pinned, reviewed and built once | a repository to serve and a signing key to keep |
| P3 | An AUR helper on each machine, driven by the host | nothing to serve | builds on every machine, unpinned: the predecessor's `git clone` in another form |
Starting position: P2, for anything with native code. ADR 0205 stays for plain files such as a
theme.
2. **Secrets in the account's environment.** A predecessor file feeds package-registry and API tokens
to the session. ADR 0203 refuses secrets in contributed values, because they travel in the clear.
The candidate is ADR 0182's third class: a module's own process writes a mode-0600 file of
exports, from secrets the vault hands it over the bus, and the shell and the session source it.
This needs its own record.
3. **Per-machine sizing and drivers.** The swap layout, the GPU, the storage pool and the boot
loader are facts of one machine's hardware. They belong in hardware modules, or in settings
(issue 168), not in the shared ones.
4. **What a module may leave behind.** Compose is installed on both servers, unowned. The mesh
removes nothing it did not make. The choice is between an operator's one-off removal and a
server-side `absent` declaration.
5. **The hosts file.** ADR 0199 (decided on an open change, not yet merged)
gives `/etc/hosts` to one module through a seat, `node-hosts-file`, with an operator region and
three verbs. It is not built. Today the private network's foundation writes only its own block, and
the rest of each file is a predecessor's stale blocks (both servers) or the operator's development
names (both workstations). The candidate module is that seat's first holder. It takes the
private-network block as a contribution, and its operator region replaces the hand-kept lines.
6. **Mounts.** The host has no shape for a filesystem mount; ADR 0091 is about what a container
mounts. One workstation mounts a share of the home server over NFS, and a second share over SMB.
That second one fails, and its credential sits in clear in `/etc/fstab`.
| | option | for | against |
|---|---|---|---|
| M1 | **A module owns `/etc/fstab`** and other modules contribute lines | one file, as people know it | the file also carries the root and boot filesystems the installer wrote, which no module should rewrite; a slot contribution into a file that can stop a machine booting |
| M2 | **Each client module writes its own systemd mount (and automount) unit**, which is the service manager's drop-in for exactly this. The `nfs-client` or `smb-client` module declares the unit file and the service shape enables it. `/etc/fstab` stays the machine's | no new host shape, and no shared file; the unit names its own dependencies (network online, the private network) and an automount does not hang a boot when the server is away; unassigning removes the mount | a mount reads as a unit, not a line |
| M3 | A new `mount` shape in the host | the host knows what a mount is | a second way to say what M2 says |
Starting position: **M2.** The credential an SMB mount needs is a secret, written by the module's
own process from the vault, mode 0600, which is question 2's mechanism. The pair is a server
module exporting (`nfs-server`, `samba`) and a client module mounting. The client requires the
share the server provides, so the mount is resolved, not hand-typed.
7. **Two DHCP clients on one interface.** The home server runs `dhcpcd`, a DHCP *client* (no machine
runs a DHCP server), next to the network manager, which is its assigned networking module. Both
lease an address on the same interface, which therefore carries two LAN addresses. The catalogue's
`dhcpcd` module is assigned nowhere, and this unit is a leftover. The network manager is the
machine's one DHCP client, and `dhcpcd` should be disabled there.
## Security findings, independent of any module
1. A filesystem credential in clear text in a workstation's `/etc/fstab`, for a mount that is failing
anyway.
2. A predecessor CI user with passwordless sudo and docker membership on the anchor, and a
predecessor sudoers drop-in. *The user was removed on 2026-10-04; the drop-in remains.*
3. The operator account in the `root` group on one workstation.
Each is one small change. None waits for a module.
@@ -0,0 +1,169 @@
# 03 — The account's own tools: ssh, scripts, mail
Three further directions from the operator on 2026-10-04. Each is account-level, like the shell
([to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)).
## `~/.ssh` is one module's
*"A module owns `~/.ssh`, so it is its responsibility that every folder is set up consistently and
correctly."*
**Measured:**
- The catalogue's `ssh-client` module owns the directory (mode 0700) and one region of
`~/.ssh/config`: a `Host` block per machine of the mesh. It owns nothing else.
- On one workstation, a predecessor's header, `Include` and hand-written host block sat **above** the
mesh's region. ssh takes the first match, so the predecessor's entries were the ones in force, for
the same machines. Removed on 2026-10-04.
- On the control machine, two keys of a retired CI system were still in the operator's
`authorized_keys`, able to log in as the operator. Removed the same day.
- Permissions differ by file and by machine. Backups of the configuration lie beside it.
**Starting position:** `ssh-client` becomes the holder of everything under `~/.ssh`, classified as
ADR 0182 asks:
| path | class | how |
|---|---|---|
| `~/.ssh/`, its mode, every file's mode | owned | the directory resource, plus a check verb that reports a file with the wrong mode |
| `~/.ssh/config` | written into, the mesh's block **at the start** | the mesh's hosts win; the operator's lines after it are kept; an `Include config.d/*` line in the block |
| `~/.ssh/config.d/<module>` | owned by the contributing module | ssh's own drop-in: a work module adds its forge's host there (research 026 C1) |
| `~/.ssh/authorized_keys` | written into, the mesh's block | the operator's keys as the mesh records them, and nothing a retired system left. The operator's own lines are kept below the block |
| `~/.ssh/known_hosts` | written into, the mesh's block | every mesh machine's host key, so the first connection never asks |
| private keys | found | never read and never written by the mesh; a key the mesh should hand out comes from the vault, through the module's own process (ADR 0182, third class) |
The sshd module is the other half: the machine's side. It is already in the catalogue.
## Scripts on every machine, shared and machine-specific
*"All nodes should get some custom scripts, both node-specific and mesh-specific (shared)."*
**Measured:** the operator's script folder holds 64 entries plus 33 in its `bin/`. It is under no
version control, and exists only where it was copied. It mixes three kinds:
1. scripts belonging to a module (the desktop's watchers, lock, menus; a laptop model's brightness);
2. the operator's own tools;
3. installers that modules have replaced.
**Starting position:**
- **The operator's scripts live in a repository of their own,** registered as any application is
([ADR 0015](../../02-DECISIONS/0015-applications-live-in-their-own-repository.md)), built as archives,
unpacked into a directory the module owns under the home. `bin/` goes on `PATH` through an
environment contribution ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)),
and small functions go into the shell through a `shell` contribution
([ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)).
- **"Machine-specific" is said by assignment, never by naming a machine**
([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md)). One repository
holds several modules:
- `scripts` (shared, on every machine);
- `scripts-workstation`;
- `scripts-media`;
- and so on, each assigned where it applies.
A script that belongs to a piece of software or hardware moves into that module instead. A flavor
inside one module is what [research 026/03](../026-the-graphical-session-as-modules/03-what-the-predecessor-taught.md)
says not to repeat.
- **A script can also be a tool.** A script with a one-line description is served by the node's
runtime, so it can be called through the mesh on any machine that has it.
- A script that needs a secret gets it through question 2's mechanism, never from a file of
environment secrets.
## The keyring
*"A keyring is also a good thing to create a module for."*
**Measured on the two workstations, which both run GNOME Keyring:**
- **On one, the keyring unlocks at login.** The login manager's PAM service includes `login`, which
carries `pam_gnome_keyring`.
- **On the other, it does not.** The PAM line is only in the screensaver's service, so at session
start the window manager runs a script that asks for the password a second time and unlocks the
keyring with it.
- **On both, the session's start script starts the daemon again** with the ssh and gpg components,
and exports the ssh agent's socket. The keyring's current release serves the ssh agent through a
separate per-user socket unit instead.
**Starting position:** a `gnome-keyring` module that holds a node seat, `node-secret-service` (the
holder of the desktop's secret service; a password manager could hold it instead). It declares:
- the package;
- its lines in the login manager's PAM file, written into, never over (ADR 0102), so login unlocks it
on every machine;
- the ssh agent's user socket, once user-scoped units ship;
- the agent's socket path as an environment contribution, which needs a machine fact for the
account's runtime directory. ADR 0203 forbids `$` in values, so `$XDG_RUNTIME_DIR` cannot be
written in one.
The second unlock prompt and the second daemon start go away.
## Mail as events
*"Ideally a mail consumer with all my mail accounts configured, so my mail is recorded in the bus."*
**Measured:**
- The predecessor polled one work mailbox every minute. It **read an access token out of the mail
client's process memory**, called a mail API with it, and raised a desktop notification per unread
message. It worked only while the mail client ran, and stopped silently when the predecessor's units
were retired.
- Two further predecessor modules served mail tools, for one provider and for IMAP.
- The mesh runs a mail server of its own for its domains.
**Not decided here; it needs an effort of its own.** The questions it would have to answer:
- **Accounts and how each authenticates:**
- IMAP with an app password;
- a provider's OAuth with a registered application;
- the mesh's own mail server, which can publish delivery itself.
An employer's tenant may forbid registering an application at all.
- **What the bus records:**
- headers and a summary as events;
- bodies and attachments in an object store the event points at;
- retention, since mail is the most personal data the mesh would hold.
- **What consumes it:** a notifier bridge to the desktop (the predecessor's notifications), search,
an agent's context.
- **Where it runs:** one long-running module, not per machine (ADR 0198).
The obvious first step is the mail server the mesh already runs.
## Power management on the laptop
*"Power management for the laptop."*
**Measured on the laptop** (a gaming model with a hybrid GPU):
- **The platform profile is driven by a vendor daemon** (`asusd`) and its CLI. The vendor CLI is
now in the official repositories; the copy installed came from elsewhere. A predecessor script
runs as a user unit and switches the profile every five seconds: quiet on battery, balanced on
mains, performance above 50 % CPU.
- **The hybrid GPU's mode** (now hybrid) is held by a second vendor daemon (`supergfxd`), which is
**not** in the official repositories. Kernel-module options for the discrete GPU's power state
and its suspend, hibernate and resume units are set by hand. Its own power daemon is masked.
- **The battery charge limit is 80 %,** set by the vendor daemon.
- **The lid and power key suspend.** The brightness key is ignored by logind and handled by the
vendor-key path. Both are logind drop-ins.
- **Memory pressure:** compressed swap in RAM (`zram`) beside a swap file and a partition;
`systemd-oomd` with drop-ins; a predecessor *memory guard* user unit that notifies before the OOM
killer acts.
- `upower` runs. There is no `power-profiles-daemon`, `tlp`, `auto-cpufreq` or `thermald`, so nothing
competes with the vendor daemon, by design.
All of it came from two predecessor modules, one of which was a laptop-model *flavor*. A desktop
received part of it (research 026/01).
**Starting position:**
- **A hardware module per machine model** (here, the laptop's model). It holds the vendor daemon and
its profile configuration, the GPU mode daemon (ADR 0205's case, or the build machine's package
repository of research 027 question 1), the discrete GPU's module options and suspend units, the
logind drop-ins, the battery charge limit, and the vendor keys and brightness. It is assigned to
the one machine of that model, and to any second one later.
- **The profile switching** moves from a polling script to the module's own long-running code
(ADR 0198). It reacts to the power-supply change event instead of polling, and its thresholds
become settings (issue 168).
- **Memory pressure is not the laptop's alone.** `zram` and `systemd-oomd` with the notifier are a
`memory-pressure` module, assigned wherever wanted. The swap layout stays the machine's (`kernel`
module, question 3).
- A **`node-power-profile`** seat (vendor daemon, or `power-profiles-daemon` on other hardware)
gives the mesh one verb, `profile`, the same on every machine that has one.
@@ -8,6 +8,8 @@ reconstructed: false
# 39. What the SDK holds, and what it refuses # 39. What the SDK holds, and what it refuses
> **The mechanism changed — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** The test of this record — frequent *and* cascading does not belong — stands and now applies to one SDK per language. Where *what it holds* names the broker client and the event consumer, read: the local protocol a tools bundle speaks to the node's runtime; the transport lives in the runtime and in no SDK, which is what keeps a bus change from rebuilding any module in any language.
_Reconciliation note (2026-09-05): supersedes the earlier "repository structure" decision, which the consolidation folded; no standalone record remains to point at, so body references to it now point at the nearest surviving record, [ADR 0015](0015-applications-live-in-their-own-repository.md)._ _Reconciliation note (2026-09-05): supersedes the earlier "repository structure" decision, which the consolidation folded; no standalone record remains to point at, so body references to it now point at the nearest surviving record, [ADR 0015](0015-applications-live-in-their-own-repository.md)._
## Context ## Context
@@ -9,6 +9,13 @@ extends: 0007-connectivity.md
# 66. Public routing is name-agnostic, its names are resolved inside the mesh, and an internal authority can certify them # 66. Public routing is name-agnostic, its names are resolved inside the mesh, and an internal authority can certify them
> **Narrowed, not replaced — 2026-10-03.** One clause of the decision below no longer holds: *publishing
> a granted name into internal resolution, mesh-wide*. A public name now resolves publicly, and only
> names under the mesh's own suffix get a private answer — [ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md).
> Inside the mesh a route is reached and certified by its internal name
> ([ADR 0151](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md)). The label, the
> node's public domain and their composition stand as decided here.
## Context ## Context
**[ADR 0007](0007-connectivity.md) and [connectivity §3](../03-DESIGN/01-to-be/08-connectivity.md) **[ADR 0007](0007-connectivity.md) and [connectivity §3](../03-DESIGN/01-to-be/08-connectivity.md)
@@ -9,6 +9,14 @@ extends: 0110-a-seat-is-a-module-assignment-from-a-closed-set.md
# 121. A system seat is named for its scope, and a module may define its own # 121. A system seat is named for its scope, and a module may define its own
> **Narrowed, not replaced — 2026-10-03.** *"`the-dns-port` → `node-dns-resolver`"* no longer holds:
> the serving role moves to mesh scope as `mesh-resolver`, one per mesh, and `node-dns-resolver` is
> retired ([ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)). The
> distinction this record kept — serving and asking are two roles, two seats — stands, and
> `node-resolver-config` is unchanged.
> **The mechanism changed — 2026-10-02, by [ADR 0190](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md).** The naming rule stands. The build role this record made mesh-scoped — *the mesh's single build machine* — is node-scoped now: `node-build-agent`, one holder per machine, every holder taking from one work queue.
## Context ## Context
[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) made seats a closed set the [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) made seats a closed set the
@@ -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 # 150. A module's own code runs as supervised processes under the module's one account
> **Widened — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** A module's long-lived process is a bundle in any language the mesh has a toolchain for, run as a unit the host writes; this record never said one language and never meant one, and 0188 says so as the rule.
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** For a module's *tools*, read that record: one runtime per node, the node's one account, bundles loaded from the memberships. This record still governs a module's long-lived processes — a daemon, a provisioner, a scheduled ingest — and the account invariant for them. > **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 ## Context
@@ -9,6 +9,11 @@ extends: 02-DECISIONS/0066-public-routing-is-name-agnostic.md
# 151. A route's internal name is composed under the node that serves it # 151. A route's internal name is composed under the node that serves it
> **Narrowed, not replaced — 2026-10-03.** *"The roster publishes it as itself, once, at the serving
> node's address"* no longer holds: a public name is never given a private answer, and resolves publicly
> ([ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)). The internal name this record
> composes is what that rests on, and stands.
## Context ## Context
A module that requires a route is given two names from one label: a public one, `<label>.<public A module that requires a route is given two names from one label: a public one, `<label>.<public
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
# 162. A merge produces a tiered plan the mesh keeps, and a module's dependencies are one relation in the catalogue # 162. A merge produces a tiered plan the mesh keeps, and a module's dependencies are one relation in the catalogue
> **Progressive insight — 2026-10-02.** The context below says a dependent is *built by whichever build machine is running — the only one there could be*. That was a fact of the day, not of the decision: since [ADR 0190](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md) a tier's asks are taken by every machine holding the build seat. The plan and its tiers are unchanged.
## Context ## Context
A merge on the forge reaches the controller as an event, and the controller asks the build A merge on the forge reaches the controller as an event, and the controller asks the build
@@ -0,0 +1,146 @@
---
topic: what runs on it
status: proposed
date: 2026-10-01
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md
---
# 164. A setting is declared with its default, its meaning and what changing it costs
## Context
The operator asked for one thing for every module, with the container runtime as the first case: **one
consistent default configuration for every machine, overridable per assignment, and easy to change
later.** The four machines' runtime configurations were each written by hand and differ — one keeps
running containers through a daemon restart and one does not, their log rotation differs, and each
names its resolver and its trusted registries in its own words.
Most of this was already decided.
[ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) said the definition is
identity and **defaults**, the assignment's settings are the configuration, *unset is the default*, and
*an unknown setting is refused*. [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) made
a setting an operator requirement whose contract is "a type and, optionally, a default", answered by
"the assignment's, or the requirement's default, or unresolved". An assignment is a module on a node,
so the node layer of a module's settings already *is* the per-assignment override, and the mesh-wide
layer is the one consistent default a person changes once.
What was built is narrower than what was decided, measured in the controller on the day of deciding:
- **Nothing declares which keys are settable.** A mergeable file's content is its defaults, and every
key of it — and every key not in it — is accepted. Nothing tells a person, or the console, what can be
set, of what type, or what it means.
- **The refusal of an unknown setting is not there for most modules.** The stray-setting report returns
nothing at all for a module with any mergeable file, because such a file "takes any key"
([issue 173](../04-ISSUES/173-a-modules-settings-reach-every-fact-it-contributes/00-report.md) left
files that way on purpose). It reports rather than refuses where it does run.
- **A value in a file that is not JSON can have no default.** `${setting:<key>}`
([ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md)) is refused when no
layer sets it — right for a mail domain, where a default is the very literal 0155 removes, and wrong
for a tunable like the resolver's upstreams, which the resolver module therefore carries as literals
in its file.
- **A setting reaches every mergeable file its module owns.** The layers are one flat map per module,
laid over each such file. Adding a setting to the resolver module for its own configuration put the
key into the container runtime's file as well — the resolver writes into that file too — and the
runtime refuses keys it does not know. The plan showed it before any push; the runtime's file was
then made to take no settings at all ([issue 198](../04-ISSUES/198-the-lans-dns-server-ran-outside-the-mesh-and-its-filter-closed-it/00-report.md)). Issue 173 stopped settings leaking into
contributions and served facts; between one module's own files the leak remains.
- **What a change costs is said per file, not per key.** A service names the files it is reloaded or
restarted on. The runtime re-reads its trusted registries on a reload and its `dns` key only when it
starts; the resolver module declared a reload, so on two machines the key was written, reloaded,
and never read, and every container got a public resolver for weeks while everything read as
current ([issue 110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/01-resolution.md)).
## Considered Options
1. **Leave settings implicit; document each module's keys in its README.** Rejected: a key the mesh
does not know cannot be refused, typed, listed by the console or costed, and a README is a rule
enforced by nothing.
2. **A second mechanism for tunables beside settings** — defaults in a new block, settings untouched.
Rejected: two ways to state one person's value, and design 27 already retires six mechanisms
that grew that way.
3. **Settings declared in the definition, as 0112's operator requirement: a key, a type, a meaning,
optionally a default, and what a change costs.** Adopted.
## Decision
**A module declares every setting it takes.** Each declared setting has a name, a type, one sentence
of meaning, optionally a default, and what a change to it costs. The spelling is design 27's to settle
with the rest of the requirement form; this record decides the content.
**A setting with a default is a tunable; a setting without one is the operator's.** A tunable resolves
to its default when no layer sets it — wherever it is read, a mergeable file or `${setting:<key>}` in a
file of any format. A setting with no default is refused by name when nothing sets it, as 0155 decided;
0155's refusal is narrowed to exactly that case, not changed for it. Whether a value has a default is a
fact about the software (a log size does, a mail domain does not), and the definition states it once.
**The layers stay as they are, and every value says where it came from.** The definition's default,
then the mesh-wide layer, then the node's — later wins, objects merge, lists replace. One consistent
configuration for every machine is the default plus the mesh-wide layer; one machine that differs says
so in its own layer and nothing else. Asked for a module's configuration on a machine, the mesh lists
every declared setting with its effective value and its source: *default*, *mesh*, or *node*.
**Changing later is changing one of three places, and the plan shows its reach before anything moves.**
A new default ships with the module's next version and reaches every assignment that does not override
it; a mesh-wide setting reaches every assignment of the module; a node's reaches one. The plan of a
change names each assignment whose effective value moves.
**A declared setting says where it lands.** Each names the file or files of its module that read it,
and reaches no other: a module that owns two mergeable files no longer has one flat map laid over both.
A file that names no setting takes none.
**A declared setting is the only kind accepted.** Setting a key the module does not declare is refused
when it is set, naming the declared keys, rather than reported when the machine is planned. The mesh's
own words — where a port, a directory or an operator's data is placed, how far an endpoint reaches —
are the mesh's to validate as they are today, and no module declares them. A module
that declares no settings keeps today's behaviour until it does; a catalogue test lists those modules,
and the list shrinks to empty before the implicit form is removed — design 27's rule for every retired
mechanism.
**A setting says what it costs: nothing, a reload, or a restart.** When a file changes, the host
applies the strongest cost among the settings whose values moved in it, so a key the software reads
only at start can no longer be written and never read. A setting that reaches a container's environment
costs that container being recreated, which the host already does when a container's specification
changes; it needs no declaration. A service's `reload-on` and `restart-on` keep
naming the files that are not settings — a generated roster, a credential.
**The container runtime is the first module to declare its settings** and the model for the rest:
its log rotation, keeping containers through a daemon restart, and its resolver are tunables, and
its trusted registries are what the mesh tells it.
## Consequences
- The console can show a module's settings as a form: what can be set, of what type, its default,
and where the current value came from. That is the surface the operator wants for changing a
default later.
- `settings set` can refuse an unknown key, so ADR 0046's rule is enforced where it was only stated.
- The resolver's upstreams, the runtime's log rotation, and other literals a definition carries
because it could not give them a default become declared tunables.
- **What got harder:** every module that takes settings must list them, and a mergeable file no
longer silently accepts a key its author did not foresee. A person who needs one adds it to the
definition, which is a new module version, not a setting.
- Issue 173's open question — a consumer checks nothing against a contract — is unchanged; this record
is the operator half of design 27's contract, not the provider half.
- Not decided here: the spelling (design 27); whether a node's layer may be narrowed to a single key
rather than replaced whole, as `settings set` does today.
## How this is checked
| Rule | Checked by |
|---|---|
| Every setting a module takes is declared | A parser test refusing a setting declaration without a type or meaning; a catalogue test listing modules with mergeable files or `${setting:}` and no declarations, which must be empty before the implicit form is removed |
| A tunable resolves to its default; an operator value without one is refused | Resolution tests: an unset tunable in a JSON file and in a text file both take the default; an unset setting with no default is refused naming it (0155's existing test) |
| A setting reaches only the files it names | A resolution test: a module with two mergeable files and a setting declared for one; the other file's content is unchanged by it (the case of issue 198) |
| An undeclared key is refused when set | A controller test: `settings set` with an undeclared key fails naming the declared keys, and nothing is stored |
| Every effective value names its source | A test listing a module's configuration on a node with one key from each of default, mesh and node |
| A change's reach is shown before it moves | A plan test: changing a mesh-wide setting names every assignment whose effective value moves and no other |
| The strongest cost applies | A host test: a file where a reload-cost key and a restart-cost key both moved restarts; a file where only reload-cost keys moved reloads |
| Live | The container runtime's module lists its settings with their sources on every machine, and a mesh-wide change to its log rotation reaches all four at the next push |
## References
- [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)
- [Design 27 — a module requires, the mesh resolves](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md)
- Issues [110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md), [173](../04-ISSUES/173-a-modules-settings-reach-every-fact-it-contributes/00-report.md)
- mesh-controller `internal/catalogue/settings.go` (`settle`, `UnusedSettings`), `internal/catalogue/setting_into.go`
@@ -0,0 +1,105 @@
---
topic: what runs on it
status: proposed
date: 2026-10-01
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0161-what-deserves-a-seat.md
---
# 165. `container-runtime` is what a machine can run; that a runtime is running is its holder's health
## Context
A capability is a requirement a module places on a machine, detected by the host and renewed with
every report ([ADR 0161](0161-what-deserves-a-seat.md)). The host's `container-runtime` asks the
daemon for its version: *a running daemon, not an installed client*. It was made that way by
[issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md), where an
installed package was believed to be a working service, and
[design 05](../03-DESIGN/01-to-be/05-the-node-host.md)'s table says the same: *a runtime is
running*. The installer's preflight borrows the same detector to wait for the runtime the
foundation bundle installs, so there is one answer to "is there a runtime here".
The mesh is now to have a module for the runtime itself — its packages, its configuration, its
service — on every machine ([ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)).
That module cannot declare `container-runtime` as defined: it would require the very thing it
installs, the cycle [research 011](../01-RESEARCH/011-the-module-graph/cases.md)'s case 12 names
("something the mesh installs that then becomes a node capability"). The operator defined the word
for it: **`container-runtime` means the machine is able, at the kernel level, to install a runtime and
execute containers** — not that one is installed, and not that one is running.
The host already draws this line once. `seat` is hardware, a display server *could* run here;
`graphical-session` is state, one *is* running; the detector's own comment says "assignment needs the
first". A machine without a display has no seat however much software is installed, and a machine
with one has a seat before anything is.
Fifty-four catalogue modules declare `container-runtime` today, counted on the catalogue's main
branch on the day of deciding: every module that delivers a container. Each relies on the current
meaning to keep it off a machine with no running runtime.
## Considered Options
1. **Keep the meaning; let the runtime's module declare nothing.** Rejected: a module that installs
the runtime has requirements on the machine — the kernel features without which installing it is
pointless — and would state none of them. The cycle stays, only hidden.
2. **Two capabilities, "can run" and "is running".** Rejected: the second is made true by assigning a
module, so it is the module's state, not a fact of the machine; a capability the mesh itself
flips by its own assignment is case 12's cycle with an extra name.
3. **The capability is the kernel's; whether a runtime runs is the runtime module's health, and a
module that delivers a container needs the runtime's seat held.** Adopted.
## Decision
**`container-runtime` is detected from what the kernel offers**, as `seat` is: the namespaces a
container needs, a control-group hierarchy the runtime can manage, and an overlay filesystem the
running kernel has or can load. Present when all three are; absent naming the missing one. Nothing is
run and no runtime is asked. The verdict's detail names what was found, not a runtime's version.
**"A runtime is running and answers" is one probe, owned by the host and used twice:** by the
installer's preflight, which waits for the runtime the foundation installs, and as the runtime
module's health. It asks the daemon, as issue 007 requires. The preflight stops borrowing the
capability's detector, and there is still one answer to "is a runtime running here".
**The runtime's module declares `container-runtime`**, with `package-manager`, `service-manager` and
`privileged`, like any module that manages machine software.
**A module that delivers a container needs the runtime seat held on its machine**, and is refused
otherwise, naming the seat and the modules that could hold it — the refusal design 27 already lists
for an unheld seat. That requirement is derived from the container resource and needs no manifest
field ([ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)).
The fifty-four existing declarations of the capability stay valid and become redundant; a catalogue
test lists them, and they retire when the list is empty.
**The order is fixed, not preferred.** The detector changes only once the seat requirement is
enforced. In between, a machine with the kernel and no running runtime would read as able to run
every containerised module, which is issue 007 again.
## Consequences
- Design 05's capability table changes its `container-runtime` row from *a runtime is running* to
*the kernel can run containers*, and names the runtime module's health as where "running" is now
asked.
- The node listing stops showing the runtime's version beside the capability. The version moves to
the runtime module's health and its seat's verbs.
- A fresh machine with no runtime reads as able to run one, so it can be assigned the runtime's
module, which is what makes the mesh able to install the runtime instead of the bootstrap alone.
- **What got harder:** "is this machine running containers" is no longer one glance at the profile;
it is the runtime seat's holder and its health. The node's listing should show both side by side.
## How this is checked
| Rule | Checked by |
|---|---|
| The capability is the kernel's | Host detector tests over a fixture `/proc` and `/sys`: all three present → present; each one missing → absent naming it; no runtime binary on the fixture machine changes nothing |
| One probe asks whether a runtime runs | A host test that the preflight and the runtime module's health call the same probe, and that the probe fails against a stopped daemon with an installed client (issue 007's shape) |
| A containerised module needs the runtime seat held | A resolution test: a module with a container resource on a machine whose runtime seat is unheld is refused, naming the seat and its candidate holders |
| The order holds | The host release that changes the detector is gated on the controller release that enforces the seat requirement — stated in both changes' descriptions and checked at review |
| Live | Every machine's profile shows `container-runtime` present with the kernel's features as its detail; a machine with no runtime installed reads present too |
## References
- [ADR 0161](0161-what-deserves-a-seat.md) — the profile renewed by every report; a capability that names a dialect
- [ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md) — the seat and its holder
- [Issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md), [research 011](../01-RESEARCH/011-the-module-graph/cases.md) cases 12–13
- [Design 05 — the node host](../03-DESIGN/01-to-be/05-the-node-host.md)
- mesh-host `internal/profile/detectors.go` (the runtime detector), `internal/profile/seat.go` (the hardware/state split), `internal/bootstrap/preflight.go` (the preflight that borrows it)
@@ -0,0 +1,161 @@
---
topic: what runs on it
status: proposed
date: 2026-10-01
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0161-what-deserves-a-seat.md
---
# 166. The container runtime is a node seat, and the host creates containers through its holder
## Context
Every container the mesh runs on a machine is created by the host, which looks for a runtime
(`docker info`, then `podman info`) and drives that runtime's command line itself: run, inspect,
remove, exec. Research 012 called this "detected rather than declared": the host takes over whatever
runtime it finds. Nothing in the mesh owns the runtime. Its package came from the foundation bundle
or was already on the machine. Its configuration file was written by hand, differs on each of the
four machines, and is also written into by two modules that are not the runtime's
([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)).
Its service is declared by those same two.
The operator set the direction:
- a module for the runtime, on every machine, owning "what is needed to run containers here": its
packages, its configuration and its service;
- that module holds a node seat for the runtime, so a second runtime (podman) can later compete
for the seat;
- the host stops speaking to the runtime directly and uses the seat's holder. The host stays the one
that decides, and the holder becomes the one that executes;
- every container on the machine is in scope, not only the mesh's. A development environment started
by hand, or a test database a tool runs, is legitimate. The host already calls these *strays*: 3,
8 and 25 on three of the machines on the day of deciding;
- the runtime's events and verbs are subjects on the bus, and the mesh's own interface is built on
them. The third-party interface run until now was removed by hand.
[ADR 0161](0161-what-deserves-a-seat.md)'s test for a seat is whether the mesh's own code finds it by
name. Here it does: the host would look up the holder on its own machine. A singular role of a module
held once per machine is a `node-*` seat ([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)),
in the controller's seed.
The constraint that decides most of this record is a cycle. The bus runs in containers. On the
broker's machine, the broker's own container is created by the host. A holder's code served from a
container cannot create the container that runs it. On a first machine, before the controller exists,
nothing holds anything.
## Considered Options
1. **The host calls the holder's verbs over the bus.** Rejected: with the broker down, no machine can
create any container, including the broker's. The mesh would be unable to restart its own
transport.
2. **The holder picks a dialect that the host speaks itself, as with the uplink.** Rejected: the
host would still drive the runtime, and the module would drive it too for every other caller.
That is two programs speaking to one daemon, and they come to disagree about the same machine
(the installer's preflight already exists to avoid this).
3. **The holder's code runs as a supervised process on the machine and serves the seat's verbs
twice: locally to the host, on the bus to everyone else.** Adopted.
## Decision
**`node-container-runtime` is a seat of the mesh's own, node-scoped,** in the controller's seed under
this record. It delivers no provision; what it carries is its role's protocol: verbs its holder must
serve ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)) and events its holder emits
([ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md)). The runtime's module, `docker`, claims
it and is assigned to every machine. A podman module may claim it later; one machine runs one.
**The seat's verbs cover every container on the machine:** list, inspect, logs, stats, start, stop,
restart, create and remove. A mesh-held container is marked by the host's label and says which
assignment holds it. **A container the runtime runs can be root on the machine** — privileged, a host
path mounted, the host's network or process namespace, the runtime's own socket — so a caller other
than the host may not create one that is any of these; only a declaration the mesh composed may ask
for them. And the verbs that change anything are granted by name, never by a wildcard: a grant of
every tool (the console's today) reaches the reading verbs only. Issue 193 is what a verb that trusts
its caller costs. **Creating or removing a mesh-held container is the host's alone.** Any other
caller is refused naming the assignment, because the host would undo it at its next apply. Starting,
stopping or restarting one is allowed, and the answer says the host will restore what its
declaration says. A container the mesh does not hold is the caller's to do anything with.
**The seat's events are the runtime's own** — a container created, started, stopped, died, removed,
its health changed. They are emitted on the seat's subjects, so every holder emits the same events and
no reader depends on which runtime holds the seat. As
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
decides, the subjects are issued by the controller, not composed by the module.
**The holder's code is a supervised process, not a container** ([ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)).
A runtime cannot be run by the thing it runs. The process serves the seat's verbs on the bus to the
console, to tools and to the mesh's interface. The same verbs are served on a local socket on the
machine, which only the host may use. **The host creates, inspects and removes its containers
through that socket and nothing else.** If the holder does not answer, the host creates nothing. It
says so in its report, naming the seat. It never falls back to the command line.
**A container needs the seat held on its machine.** An assignment that delivers a container on a
machine whose runtime seat is unheld is refused, naming the seat and its candidates
([ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md)).
Mounting the runtime's socket into a container is granted by the seat, not by the capability. The
socket's path is the holder's to state, because podman's is not docker's.
**The runtime module owns the runtime's configuration.** Its settings are declared with defaults
([ADR 0164](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md)):
the resolver containers use, the registries it trusts, log rotation, and keeping containers through a
daemon restart. The module is given the resolver's address and the mesh's registry as values; no other
module writes the runtime's file or declares its service.
**The first machine is bootstrapped with the holder, and adopted afterwards.** The foundation bundle
already installs the runtime's package and service. It also carries the holder's process, delivered as
a binary the way the host is ([ADR 0142](0142-the-mesh-delivers-its-own-components-as-binaries.md)).
When the runtime module is assigned, it adopts what the bundle made, as the store and broker modules
adopt theirs ([ADR 0078](0078-the-store-and-broker-are-modules.md)).
## Consequences
- **The migration on the running mesh has a fixed order:**
1. Each machine's hand-written configuration is read, because the module's defaults replace what
differs.
2. In one push per machine: the resolver module and the private network stop writing the
runtime's file ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)),
and the runtime module is assigned and adopts the runtime, its file and its service. Split in
two, either the controller refuses two modules declaring one path, or a machine is left with
nothing setting `dns` and `live-restore`.
3. The controller seeds the seat and enforces the container requirement.
4. The host releases the version that uses the holder.
5. The host's command-line path is removed in the release after every machine's holder answers.
Until then, the host reports per machine which path it used.
- **Every container the host makes depends on the holder's process.** A crash-looping holder stops
new containers on its machine. Running containers are unaffected. The host's report names the cause.
- The process form of a module's own code must serve tools on the live mesh before this ships. Only the
showcase declares it, and [issue 117](../04-ISSUES/117-a-modules-own-code-is-a-container-and-a-process/01-diagnosis.md)
found the showcase's tools declared in a form nothing runs. The runtime module is the first whose
tools cannot fall back to a container.
- A user interface subscribing to events directly does not exist. Today a reader of events is a module
that consumes them. The mesh's container view is a module, or waits for that path.
- [ADR 0005](0005-the-node-host.md) ("a container runtime is detected, not chosen") and
[ADR 0006](0006-the-substrate-and-the-control-plane.md)'s matching line describe the mechanism this replaces: on
acceptance, each gets a dated note saying the runtime is now a seat's holder, as the decision
records' rule for a moved mechanism requires. Design 05 and design 26 are amended after acceptance.
- The operator's decision to remove the third-party interface by hand needs no mechanism. No
module-retires-module rule is introduced.
- **What got harder:** the host gains a dependency it did not have, and a first machine's bundle gains
a component. The direct path was simpler and is what makes a runtime a black box to the rest of the
mesh.
## How this is checked
| Rule | Checked by |
|---|---|
| The seat is the mesh's own, node-scoped, with its verbs and events | A catalogue test on the default seats; registration refuses a claimant that does not serve every verb (design 33's existing check) |
| Creating or removing a mesh-held container is the host's alone | A test of the runtime module's verbs: create or remove of a container carrying the host's label, from any caller but the host's socket, is refused naming the assignment; the same verbs on an unlabelled container succeed |
| No caller but the host creates a container that is root on the machine | A test of `create` from the bus: privileged, a host path, the host's namespaces and the runtime's socket are each refused; the same request on the host's socket is accepted. A broker test: a grant of every tool does not reach a changing verb |
| The host uses the holder and never the command line | A host test with a fake holder on the local socket: every container operation goes to it, and with the holder absent the apply creates nothing and reports the seat; after step 5, the host carries no command-line runtime code (checked by build: the package is gone) |
| A container needs the seat held | A resolution test refusing a containerised assignment on a machine with the seat unheld, naming the seat |
| Socket mounts are granted by the seat | A catalogue test: a module mounting the runtime's socket on a machine whose holder states a different path is refused |
| No other module writes the runtime's file | The existing collision check, once the private network's computed resources are inside it ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)) |
| Live | `seats` lists `node-container-runtime` held on every machine; the node listing shows each machine's containers, strays included, from the seat's `list` verb; a container started by hand appears as an event on the bus |
## References
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [ADR 0161](0161-what-deserves-a-seat.md)
- [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md), [ADR 0142](0142-the-mesh-delivers-its-own-components-as-binaries.md), [ADR 0078](0078-the-store-and-broker-are-modules.md), [ADR 0005](0005-the-node-host.md)
- [ADR 0164](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md), [ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md), [issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)
- [Design 26 — the seats](../03-DESIGN/01-to-be/26-the-seats.md), [design 33 — the tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)
- mesh-host `internal/apply/apply.go` (the runtime lookup and the command line it drives)
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
# 174. A node varies a module through settings and kept regions, never through an edit # 174. A node varies a module through settings and kept regions, never through an edit
> **The mechanism changed — 2026-10-04, by [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md).** Where this record calls a kept region *a marked block in which the operator's own lines are kept*, read the inverse, which is what the host built: the mesh's region is the marked block, and every line outside it is the operator's, kept byte for byte and given back when the module goes. The decision stands: a node varies a module by settings and by the operator's own lines, never by an edit.
## Context ## Context
[ADR 0011](0011-managed-files-are-generated-never-edited.md) says a managed file is derived and an [ADR 0011](0011-managed-files-are-generated-never-edited.md) says a managed file is derived and an
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under
# 175. One tool runtime per node serves every module's tools, on the host side # 175. One tool runtime per node serves every module's tools, on the host side
> **The mechanism changed — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** Everything decided here stands: one runtime per node, host-side, every module's tools and every held seat's verbs on the memberships' subjects, root the module's concern, any node calls any tool, the console its serving mode. What moved is how the runtime brings a bundle to life. Decision 3 and the consequence *the node tools runtime needs an interpreter on the machine* read as though a bundle were always interpreted code the runtime imports; a tools bundle is now a process in any language that speaks MCP over stdio to the runtime, and importing a TypeScript bundle is the shortcut, not the contract.
## Context ## Context
A module's tools are code the module wrote, one function behind each verb, served on the subjects A module's tools are code the module wrote, one function behind each verb, served on the subjects
@@ -9,6 +9,8 @@ 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 # 176. The login shell is a node seat held by one shell module, and `execute` is its contract
> **The mechanism changed — 2026-10-04, by [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md).** The seat is no longer declared by the shell modules (§1). It is `node-login-shell`, in the mesh's own seat set, which a shell module claims. Its holder also places the shell code other modules contribute, and sources the account's environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)). What stands: one holder per node, the login shell set by the `user` shape and given back, `execute` as the contract, and any node may call it.
## Context ## Context
[ADR 0040](0040-what-a-module-is.md) names the shell as its example of a *shared* seat: bash, zsh [ADR 0040](0040-what-a-module-is.md) names the shell as its example of a *shared* seat: bash, zsh
@@ -9,6 +9,12 @@ 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 # 181. The operator account is a node fact, and a home is a placement root
> **Progressive insight — 2026-10-04.** This record called a resource under a home *home-scoped*, and a
> module that places one a *home-scoped module*. There is no such kind of module
> ([ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) §2: a module is
> what it declares), so the three places now say *a resource placed under a home* and *a module placing
> files under a home*. What was decided is unchanged.
*Reconstructed. The controller shipped this on 2026-09-27 and *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 [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 decision behind it. This record states what was decided, from the code and the design, and adds the
@@ -35,7 +41,7 @@ its home; the account and its home are machine facts a definition may name in a
and content; a roster file may say it lives under the home, and is then rendered per node, placed under 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 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 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. stated it, so no resource placed under a home can land anywhere yet.
## Considered Options ## Considered Options
@@ -68,7 +74,7 @@ account. A definition names the account and its home as machine facts, never as
may say it is a home file and is then placed and owned the same way. The controller resolves both at 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. 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 **A node with no account cannot carry a resource placed under a home, 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 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. 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 A module that writes a person's files is thereby unassignable to a machine with no person on it, which
@@ -80,7 +86,7 @@ anything.
## Consequences ## Consequences
- **The operator states the account before any home-scoped module lands.** Today none is stated, so the - **The operator states the account before any module placing files under a home lands.** Today none is stated, so the
first assignment of such a module begins with four node records. 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 - 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. on every machine — the gap that surfaced this, closed by the same fact.
@@ -9,6 +9,12 @@ extends: 02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-
# 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 # 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
> **Progressive insight — 2026-10-04.** This record said *a home-scoped module* and *the family of
> home-scoped modules*. There is no such kind of module
> ([ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) §2), and the rule
> is about a directory under a home, whichever module declares it; the three places now say so. The
> decision, its options and its consequences are unchanged.
## Context ## Context
[ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) lets a module [ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) lets a module
@@ -35,7 +41,7 @@ use tools that no longer exist. Nothing owns them; nothing will ever rewrite or
`~/.ssh`: the mesh owns the directory and the files it places; it holds the person's private keys and `~/.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 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 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 to hold for every directory under a home that any module will touch, so it is a rule, not a
section. section.
## Considered Options ## Considered Options
@@ -52,7 +58,7 @@ section.
## Decision ## Decision
**A home-scoped module owns the directory it declares: its existence, owner and mode.** The host creates **A module that declares a directory under a home owns that directory: its existence, owner and mode.** The host creates
it if absent, owned by the account, and never removes it while it holds anything 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 ([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 is in exactly one of four classes, and **the class is visible in the definition from the shape
@@ -105,7 +111,7 @@ finished its definition.
| An owned file found with no record is kept once, then written | host tests of ADR 0102's kept-original rule | | 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 | | 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 | | 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) | | Every path a module touches under a home 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 ## References
@@ -152,6 +152,30 @@ the node is bound to, and refuses with a notification otherwise.
| 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 | | 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 | | 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 |
> **The mechanism changed — 2026-10-03, by [ADR 0193](0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)
> and [ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md).**
> What stands: one manager holding the seat, one rotation source, a token sealed to the receiving
> module's key on request/reply and never an event, the agent module alone writing what the agent
> reads, the identity guard, the host knowing nothing. What moved: both modules' code is bundles the
> node's runtime launches over stdio and is the bus for — `mesh/ask` for a call made on the module's
> behalf, `mesh/publish` and `mesh/subscribe` beside it — so neither holds a bus credential of its own.
> The manager's refresh and visits are a long-running bundle the control node's runtime launches. And,
> by the operator's direction, **the manager starts every exchange**: it asks each bound node's agent
> module for its public key, hands it a token, asks it for a login waiting to be adopted, and reconciles
> every node on a schedule — which is what "the agent module asks the seat for its current token" and
> "offers the grant to the manager" in the decision above now mean in practice. The agent module could
> ask through its runtime; it does not need to.
> **The mechanism changed — 2026-10-04, by [ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md).**
> What stands: the manager holding the seat, one rotation source, the grants encrypted in its store, a
> token sealed to the receiving module's key on request/reply and never an event, the agent module alone
> writing what the agent reads, the identity guard, bindings as a person's act. What moved: the dated note
> above — the manager no longer starts every exchange. Each node reports what it holds as state, without
> the secret; the manager asks a node for its grant only when a report shows one it does not hold, adopts
> a licence by refreshing it rather than into a licence configured beforehand, and keeps what each
> consumer should hold as state, from which the node fetches its token by request. The rotation and switch
> events are gone.
## References ## 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 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
@@ -0,0 +1,131 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
---
# 188. A module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime
## Context
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) put one
tool runtime on every node and said a module brings its tools as a bundle. The runtime that exists
is written in TypeScript and brings a bundle to life by **importing it into its own process**, which
only JavaScript can be. The SDK ([ADR 0039](0039-what-the-sdk-holds-and-refuses.md)) is one
TypeScript package. The builder knows three toolchains — TypeScript, Go, Python — and every one of
the 35 catalogue modules with tools wraps them in a container on the runtime's TypeScript image.
Nothing in the records says a module's code may be written in anything else, and nothing refuses a
module that wraps its own code in an image to get around that.
The operator's direction, stated on 2026-10-02 and repeated: *the SDK is the most important part;
we must not limit developers; tools can be written in any possible language — Rust, C, Go,
JavaScript. A service in Go or Rust as a systemd unit must be possible too. One module can deliver
all kinds of bundles: one for its tools, one for a seat's implementation, one for a daemon. Support
the bare minimum first, as a skeleton; a full implementation comes when the work requires it.*
Measured against that: the `bundle` artifact kind already names a language and the `process`
resource already runs a command from an unpacked bundle as a unit the host writes
([ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)), so a Go
daemon as a native service is possible today and one module in the catalogue does it. What is not
possible is a tool in any language but one, and what is not written is that any of this is the
rule.
## Considered Options
1. **One SDK, one language, as now.** Rejected: it limits who can write a module to one
ecosystem, which the operator declines, and it is what made every module's tools a container
on one image.
2. **A full bus client per language.** Each SDK speaks the bus itself; the runtime only
supervises. Rejected: a transport in every SDK is what ADR 0039 refuses, and a bus change
would then rebuild every module in every language — the cascade, multiplied.
3. **A tools bundle is a process the runtime launches and speaks a small local protocol to,
and that protocol is MCP over stdio.** Chosen. The runtime already speaks MCP outward (the
console); speaking it inward to a child process is the same vocabulary. Every language that
has an MCP server library can write a tools bundle today with no mesh SDK at all, and the
mesh's own SDK for a language is a thin convenience over it. The transport stays in the
runtime, so a bus change rebuilds nothing.
4. **A protocol of the mesh's own design.** Rejected: a second way to describe a tool, its
schema and its call, inventing what MCP already settled, for no gain.
## Decision
**1. A module's own code is bundles, in any language the mesh has a toolchain for, and never an
image.** A `bundle` names its language and what it is for. Images are for third-party software a
module installs — a database, a forge — never for code the module wrote. One module may declare
several bundles: its tools, its implementation of a seat's verbs, a daemon, a step. Each is built
alone and delivered alone, as [ADR 0156](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)
already has it.
**2. A bundle the runtime serves is a process that speaks MCP over stdio.** The node's runtime
launches it as the bundle names it — an interpreter and a file, or a binary — with the runtime's
environment, asks `tools/list`, and answers each call on the bus by `tools/call`. A tool whose name
is `<seat>.<verb>` is the module's implementation of that seat's verb; any other name is the
module's own tool. Everything the runtime does with what it is told — subjects from the membership,
a held seat's verbs, the `tools` answer, a bundle that fails named and the others serving — stays as
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) and
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
have it. A TypeScript bundle may still be imported into the runtime's own process; that is a
shortcut over the same contract, not a second contract, and a TypeScript bundle written against
the protocol is served the same way as any other.
**3. A bundle that is a service is a `process`**, run by the host as a unit, in whatever language it
is compiled from, exactly as the host's own bundle already is. Nothing new is decided here; it is
said so that it is the rule and not an example.
**4. One thin SDK per language, and the test of ADR 0039 applies to each.** An SDK for a language
holds the MCP-over-stdio loop, the tool-definition type and the few primitives a module's code
needs; it holds no transport, no module's client and nothing volatile. Where a language has a
sound MCP library, the SDK wraps it rather than re-implementing it. The languages are those that
make sense to write a module in; the first set is TypeScript, Go, Python, Rust and C, and the set
grows when a module needs one, not before.
**5. Skeleton first.** Each piece — a toolchain, a launcher, an SDK — exists at the bare minimum
that lets one bundle in that language be built, delivered and answer one tool on the live mesh.
Anything beyond that is added when a module needs it. A skeleton that is not proven by one bundle
answering is not a skeleton; it is a promise.
> **The mechanism changed — 2026-10-03, by ADR 0193.** §2's allowance that a TypeScript bundle may
> be imported into the runtime's own process is withdrawn: every served bundle is launched, and the
> build makes each served entrypoint executable. The rest of §2 stands.
## Consequences
- The runtime gains a launcher beside its loader. The loader, the memberships, the seats and the
failure handling built for ADR 0175 stand; the launcher is the one new step.
- The builder gains a toolchain per language, each at the skeleton: compile, pack, name the
entrypoint. Rust and C are new; a language that compiles to a binary says its operating system
as a Go bundle already does.
- An existing MCP server in any language is already a valid tools bundle. What the mesh adds is
the subjects, the seats and the memberships around it.
- The gate [to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP2 adds —
refusing a tools container built on the runtime's image — widens: a module whose own code is
an image artifact is refused at registration, naming this record.
- What got harder: a tools bundle is now a process per module on the node rather than code in
one process, so the runtime supervises children and restarts one that dies. The one-process
shape ADR 0175 counted on for the TypeScript shortcut remains available for it.
- ADR 0039's "what the SDK holds" now reads per language; its refusals are unchanged and are the
reason option 2 was rejected.
## How it is checked
| Rule | Checked by |
|---|---|
| A module's own code is never an image | the catalogue's registration check: a manifest with a `bundle` kind of own code *and* an image artifact built from the module's own directory is refused, naming this record |
| A tools bundle in a language other than TypeScript answers on the bus | the runtime's tests: a bundle written against the protocol in a second language, launched, its tool called over a real bus |
| A TypeScript bundle written against the protocol is served like any other | the same tests, with the TypeScript shortcut off |
| Each SDK is thin | each SDK's own README states what it holds under ADR 0039's test, and its size is in the mesh's records |
| Live | a tool in a compiled language answers from the node's runtime on one machine |
## References
- [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
[ADR 0039](0039-what-the-sdk-holds-and-refuses.md),
[ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md),
[ADR 0156](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md),
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
- [To-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) — the work packages this
record widens
- The Model Context Protocol's stdio transport — the local protocol a tools bundle speaks
@@ -0,0 +1,141 @@
---
topic: the mesh
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md
---
# 189. The store keeps what the records name, and a maintenance step holds its writers still
## Context
The mesh's artifact store has never collected anything
([issue 108](../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md)).
Every build pushes another layer set; nothing has ever removed one. The predecessor ran a routine
on a timer — stop the registry, collect, start it — and the conversion carried the settings that
routine depends on without the routine, because the routine was a script beside the module and not
a resource in it. The store now holds fifty-three repositories on the machine that serves
everything else, and the only outcome of leaving it is a full disk reported as somebody else's
failure.
Three things stood in the way, and the issue names all three.
**Nothing in the mesh's vocabulary expresses a maintenance window.** The collector requires every
writer stopped while it runs. A `run-once` step runs *beside* containers, not instead of them, and
a scheduled step is the same container on a cadence. There is no way for a module to say *hold this
container of mine still while this runs*.
**Deletion is not enabled, and the door it would be enabled on has no accounts.** The store is
internal, reached by name over the overlay, trusted because being on that network is the permission
([ADR 0082](0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md)). The predecessor
kept deletion behind an authenticated door, which it could, having one.
**Nothing says what may be removed.** The registry's own answer — collect everything no tag names —
is wrong here. The mesh pushes each artifact under one moving tag and pins machines by digest, so
every build but the newest is untagged and some machine may still be running it.
## Decision
**1. Deletion is enabled on the store's one door, and the overlay stays the permission.** The
objection dissolves on inspection: that door **already accepts a push**, and a writer who can push
can replace any tag in the store with anything it likes. Delete takes nothing a push did not
already have, and the machines that can reach the door are the ones the mesh's own filter admits
([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)). Putting an authenticated
door in front of deletion while leaving push open would be a lock on the window beside an open
door, and it would cost the thing ADR 0082 bought: a store every machine can reach without a
credential to distribute first.
**2. The mesh deletes what it made and no longer keeps; the store reclaims the bytes.** Two halves,
each doing what only it can.
The **mesh** decides. It does not need to enumerate the store to do it — it has never put anything
there it did not record, so **every digest it could remove is already in its own build records**.
It deletes those manifests through the store's door, by digest, and remembers that it did.
The **store** reclaims. A deleted manifest frees no bytes until the registry's own collector walks
the storage with nothing writing to it, so the module declares that collector as a scheduled step
with the server held still for its duration. Plain collection, not `--delete-untagged`: what the
mesh keeps is still a manifest in the store, so it is still referenced, so its blobs stay — the
dangerous flag is not needed at all once the mesh is the one deciding.
**3. What the mesh keeps, stated as three reasons rather than a number.** A digest is kept because:
- **a definition names it** — every artifact reference in any module's current recorded manifest,
which is what the mesh would hand a machine now. No age limit: this is the floor;
- **the mesh can still go back to it** — every artifact of the **five most recent successful
builds** of each module, so a release that turns out wrong has somewhere to return to;
- **nothing else.** An artifact older than that, which no definition names, is what the store is
carrying for no stated reason.
A digest the mesh did not record making is never touched. That is not a safety margin, it is the
whole rule restated: the mesh removes what it put there and can account for, and the images genesis
pushed before any record existed are exactly what this must not reach
([04-ISSUES/102](../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md), F4).
**4. A scheduled step may hold its module's own containers still while it runs** —
`while-stopped`, naming resource ids in the same module. The host stops each, runs the step, and
starts them again **whatever the step did**, including when it failed or the host was interrupted.
Three boundaries:
- **Its own module's containers only.** A module that could quiesce a neighbour could stop the
mesh; a maintenance window is a statement about one service's own insides.
- **Scheduled steps only, not `run-once`.** At apply time the host already has a window: the
declaration is applied in order and a step gates what follows, so a one-time offline migration
says *before* rather than *instead of*. A recurring window is the case order cannot express.
- **Restoring is not conditional.** A step that fails must leave the service running; the whole
risk of this field is a window that never closes.
**5. The sweep runs where the records change — after a build the mesh recorded.** That is the
moment new bytes landed and the moment the keep set moved, and it needs no new timer. The
store's collection runs nightly, because reclaiming is slow and the thing it reclaims is already
unreferenced.
## Consequences
- Disk stops growing without bound on the machine that serves the mesh. That is the whole point
and it has no other way to be true.
- A machine behind by more than five builds of a module, which recreates a container, cannot pull
what it was running. It is already a machine the mesh reports as behind, and the answer is the
one the mesh already gives it: the current declaration. Stated here rather than discovered.
- The store is a little less of a museum. A digest in an old build record may no longer be
fetchable, and the record still says what that build made — the record is history, not an
index of what is on disk. The collected mark is kept beside it so the two can be told apart.
- `while-stopped` is a second thing the host does to a container it did not start this pass. It is
deliberately the narrowest form: the module's own, by id, restored unconditionally.
- The store is briefly unavailable each night, for as long as collection takes. Everything that
pulls from it retries; nothing in the mesh treats a momentary store as a failure
([ADR 0185](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md)).
- **An apply arriving during the window reopens it**, because the host's rule for a container it
finds stopped is to replace it, and `while-stopped` is the first thing that makes a stopped
container intentional. Found by reading this before it merged, recorded as
[issue 224](../04-ISSUES/224-an-apply-reopens-a-maintenance-window-by-recreating-what-it-held-still/00-report.md)
rather than fixed here: the two candidate fixes — the window takes the apply lock, or the apply
learns which containers are held — are each a decision with its own cost, and neither belongs
inside this record. Nothing is worse than it was; the store has never collected at all.
- **The sweep is bounded**: at most two hundred artifacts and sixty seconds per build, stopping at
the first refusal, because it runs inside somebody's build. What is left over is offered again
next time. The store stops growing from the first sweep; it does not empty in one.
## How this is checked
- The host: a scheduled step with `while-stopped` stops the named containers before the run and
starts them after; it starts them again **when the step fails**; it refuses an id that is not a
container of the same module, its own id, and `while-stopped` on a `run-once` step. Each refusal
is tested for what it says, not only that it says something.
- The controller: given build records and current manifests, the keep set holds every reference a
manifest names and every reference of the five most recent builds per module, and nothing else;
a reference the mesh never recorded is never in the delete set; a delete that answers 404 is
recorded as collected rather than retried forever.
- The sweep is tested against a fake store that records what it was asked to delete, so what is
asserted is the decision and not the registry's behaviour.
- Live: the store's size before and after the first nightly collection, read from the machine.
## References
- [issue 108 — the registry has no garbage collection](../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md)
- [ADR 0082 — the registry is reached by name and trusted by the overlay](0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md)
- [ADR 0053 — a step that runs on a schedule](0053-a-step-that-runs-on-a-schedule.md)
- [ADR 0156 — an artifact is what a build produces, and the store is named for its scope](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)
- [design 32 — what a module declares](../03-DESIGN/01-to-be/32-what-a-module-declares.md)
@@ -0,0 +1,117 @@
---
topic: the mesh
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
---
# 190. A seat's work is shared by its holders, and building is the first such role
## Context
Work addressed to a role goes to the seat's `accept` subjects, on a per-seat work queue
([design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1, [ADR 0041](0041-events-are-a-relationship.md)).
The holder's worker on that queue is already a queue group — *"even though the seat guarantees one
holder … the day somebody allows two holders for throughput, every message is processed twice with
nothing reporting it"* — and design 25 already says what a build queue shared by several machines is:
*a seat's `accept` subjects, on a work queue with a queue group of holders*. The mechanism was drawn.
Two things stopped it being used.
First, the build role is a **mesh-scoped** seat, `mesh-build-machine`, so there is one holder in the
whole mesh ([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md):
*the mesh's single build machine*). Second, the worker is a push consumer with **one delivery in
flight** — set so after 2026-10-01, when a push consumer handing out many at once left twenty-six of
forty-three asks undelivered ([issue 175](../04-ISSUES/175-an-announcement-behind-a-long-build-comes-back/00-report.md)) —
and one in flight on a shared consumer is one build at a time across every holder there could be.
Measured on 2026-10-02: a change to code comments in the tool runtime rebuilt its thirty-five
dependent images, one after another, on one machine, for about half an hour, while three other
machines with a container runtime sat idle; the work the mesh wanted next waited behind it. The
operator's words: *this is our first occurrence of a mesh advantage* — and: *make sure the setup is
done generically, so if another module also requires mesh functionality it can re-use the pattern.*
## Considered Options
1. **A second build machine by configuration** — a concurrency setting on the one holder, or a second
holder admitted by hand. Rejected: a setting on one machine shares nothing, and a second holder
of a mesh-scoped seat contradicts what a mesh seat means.
2. **A build-specific dispatcher** — the controller choosing a machine per build and asking it by
name. Rejected: it reinvents the queue the bus already is, it makes the controller a scheduler,
and it is specific to building; the next role needing the same would build its own.
3. **A seat's work is shared by its holders, and the build role becomes node-scoped.** Chosen. It is
what the bus was drawn to do, it is one rule for every role rather than one for building, and
"the machines that are online and hold the seat" is exactly the set a queue group's members is.
## Decision
**1. Work asked of a seat is taken by whichever of its holders is idle.** Every holder of a seat with
`accepts` reads the seat's one work queue; a node-scoped seat held on several machines has several
holders, and an ask goes to one of them. The asker addresses the role — `mesh.seat.<seat>.accept.<verb>`
— and never a machine. The outcome, the role's own event, says which machine did the work (`on`), as a
build's already does ([ADR 0157](0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)).
**2. A holder takes one ask at a time, when it is idle, by pulling.** The worker is a pull consumer:
a holder fetches one ask, works it, acknowledges, fetches the next. The server never hands an ask
to a busy holder, so a slow machine never holds work an idle one could take — the fault issue 175
found in push delivery is removed by the shape rather than by a limit, and the one-in-flight limit
that made the shared queue serial goes with it. A holder that dies mid-work leaves its ask to be
redelivered to another, as today.
**3. Work that must run on one particular machine is not a work queue.** That is a node seat's verb
asked of that machine ([design 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §4), and
nothing here changes it. A role's work queue is for work whose result is the same whichever holder
does it: a build is, because what comes out is published by digest to the mesh's store.
**4. This is one pattern, not one role's.** Any module that declares a node-scoped seat with
`accepts` gets decisions 1 and 2 with no further mechanism: the controller derives the queue and the
worker, the holders pull, the module's manifest says what every holding machine must have. The
build agent is the first; a module needing work done *somewhere on the mesh* — a scan, a
conversion, a fetch — declares a seat of its own the same way
([ADR 0126](0126-a-module-declares-its-own-seats.md)).
**5. Building is the first such role.** The build role is `node-build-agent`, scope node, with the
same `build` ask and the same `started`, `built` and `log.<id>` events as before. Its holder is the
`build-agent` module: the builder as it is — a container runtime, the artifact store and the package
registry resolved as provisions, a workspace, the bus credential — assignable to every machine that
has a container runtime. `mesh-build-machine` and the `builder` module are retired when the new
holder is assigned where the old one was. The tiered plan ([ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md))
is unchanged: a tier's asks go out together and are now worked together.
## Consequences
- A tier of thirty-five images is built by as many machines as hold the seat and are online. A
machine that is off builds nothing and blocks nothing.
- Every holding machine fetches base images from the store and pushes what it builds; the store is
reached as a provision, so this is what the provision was for. A machine with a slow link builds
slowly, and takes fewer asks for it, which is the point of pulling.
- A build's outcome carries which machine built it, so a build that fails on one machine and not
another is a fact the record shows, not a mystery.
- What got harder: a build's cache is per machine, so a cold machine pays the first pull of every
base it has never seen; the artifact store is now asked by several machines at once, and the
package registry likewise. Both are provisions and both are made for that.
- ADR 0121's *"the mesh's single build machine"* and ADR 0162's *"built by whichever build machine
is running — the only one there could be"* were true and are no longer; both records carry a note.
## How it is checked
| Rule | Checked by |
|---|---|
| Two holders of one seat each take one of two asks, and a third ask waits for the first to be idle | the controller's test over the work queue against a real bus: two machines bound to one worker, three asks |
| An ask is never delivered to a busy holder | the same test: the busy holder's ask count stays at one until it acknowledges |
| A holder that dies mid-work leaves its ask for another | the same test, one holder closed mid-ask |
| The asker names no machine | the controller's seat table: `node-build-agent` accepts `build` and the asking side publishes to the seat's accept subject, as the existing tests of `build` already require |
| Live | `builds` shows a tier's builds `on` more than one machine within one plan; `seats` shows `node-build-agent` held on every machine with a container runtime — *held on all four machines and a build taken by a workstation's agent, 2026-10-03* |
## References
- [Design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 and §5 — the work queue and the queue
group of holders this uses as drawn
- [Design 18](../03-DESIGN/01-to-be/18-building-a-module.md) — building a module, amended for
where a build runs
- [ADR 0041](0041-events-are-a-relationship.md), [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md),
[ADR 0126](0126-a-module-declares-its-own-seats.md), [ADR 0157](0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md),
[ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
- [Issue 175](../04-ISSUES/175-an-announcement-behind-a-long-build-comes-back/00-report.md) — why the
worker had one in flight, and why pulling removes the cause rather than the symptom
@@ -0,0 +1,120 @@
---
topic: the tiers
status: accepted
date: 2026-10-03
deciders: jochen
reconstructed: false
supersedes-in-part:
- 0066-public-routing-is-name-agnostic.md
- 0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md
---
# 191. The mesh's resolver holds only the mesh's own names; a public name resolves publicly
> **Progressive insight — 2026-10-03.** The first implementation told the mesh's names from public
> ones by their spelling — a name ending in the mesh suffix — and this record said so: the Decision
> read *"only names under its own suffix"*, and the roster check *"every name the roster carries ends
> in the mesh suffix"*. The mesh needs no such test, nor any per-route name: domains are a node's. A
> node has **one internal domain**, `<node>.internal`, and every route on it is a name under that domain
> (ADR 0151), answered by one wildcard per node; a node has **one or more public domains**, which public
> DNS answers. So the mesh's resolver holds the nodes' internal domains and nothing else, and the roster
> carries the machines and no routed name. Both sentences now say that; what was decided — a public
> name is never given a private answer — is unchanged.
## Context
**[ADR 0066](0066-public-routing-is-name-agnostic.md) published every routed name into internal
resolution, mesh-wide, at the address of the node that serves it.** The reason was an internal
certificate authority in the lab: it validates by connecting to the name it certifies, and a routed
public name that nothing inside the mesh resolved could not be certified.
[ADR 0151](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md) kept it:
*the roster publishes it as itself, once, at the serving node's address.*
**So every machine's resolver answered public names with private-network addresses.** On a
production mesh on 2026-10-03, each machine's hosts region carried 47 lines of the form
`<private address> <label>.<public domain>` — every public name of the control-node at its tunnel
address, every public name of the home server at its own. For the machines themselves this is merely
a detour: their traffic to a public name goes through the tunnel instead of the internet.
**For anything that is not a member it is an outage.** The home server's resolver also answers its
LAN — a listen address added as a setting on 2026-10-02. A phone on that LAN asked for the mail
server's public name, was given the control-node's tunnel address, and could not connect:
*couldn't connect to host, port: 10.10.0.1:143*. Every public name of the mesh failed the same way for
every non-member on that LAN — a phone, a television, a guest — while every check the mesh runs
reported success, because every check runs from a member.
**And the reason for publishing them is gone.** ADR 0151 gave every route an internal name,
`<label>.<serving node>.internal`, under the node's own name. It resolves inside the mesh without any
entry of its own, the proxy serves it, and the internal authority certifies it — the proxy has two
authorities since 2026-09-25: a public one for public names, the internal one for internal names.
Measured the same day: `drive.<control-node>.internal` resolves to the control-node's tunnel address
and answers 200 with a certificate that verifies against the internal root. Nothing the mesh runs
needs a public name to resolve to a private address. The one consumer that did — an internal
authority validating a public name — is the case the second authority removed.
The predecessor's resolver held exactly this and no more: an address per machine under `.internal`,
and everything else forwarded to public resolvers.
## Considered Options
**1. Keep publishing public names; stop the resolver answering the LAN.** Fixes the phone and
nothing else. The mesh would still hold a second, private answer for names the public DNS already
answers — two answers for one name, which disagree by design and are correct in different places.
And it forbids a reasonable setup: a home server's resolver serving its own LAN.
**2. Answer per source: private addresses to members, public ones to everyone else.** Split-horizon
by client. It is what a resolver serving two audiences would need *if* the private answer were worth
giving. It is not — option 3 shows nothing needs it — and it makes a name's address depend on who
asks, which is the hardest kind of fault to see from a member.
**3. The mesh's resolver holds only the mesh's own domain.** Names under the mesh suffix — machines,
and routes' internal names under them — resolve to private addresses. Every other name, including
every public name the mesh serves, is forwarded and resolves publicly. Chosen.
## Decision
**The mesh's resolver holds each node's internal domain and nothing else** — `<node>.internal` and
everything under it, at that node's private address. A machine's name,
and through it every `<label>.<node>.internal`, resolve to that machine's private address. **A public
name is never given a private answer by the mesh**: it resolves through public DNS to the public
address, from members and non-members alike.
This replaces ADR 0066's clause *"when the proxy is granted a name, the mesh publishes that name →
the node that serves it into internal resolution, mesh-wide"*, and ADR 0151's *"the roster publishes
it as itself, once, at the serving node's address."* Everything else in both stands: the label, the
node's public domain, the composition, and the internal name under the serving node.
**Inside the mesh, a route is reached by its internal name.** A container or a validator that must
reach a routed service inside the mesh uses `<label>.<node>.internal`; the internal authority
certifies that name, and a public authority certifies the public one. A mesh with no public
reachability — the lab — certifies its internal names and has no public names to resolve.
## Consequences
- **A resolver serving a LAN is safe.** What it adds to public resolution is the mesh's own domain,
which no public resolver answers.
- **A member reaches a public name over the internet, as anyone does.** A route the proxy restricts
to the private network is reached by its internal name, never by its public one — a public name
is, by this decision, public.
- **The internal authority certifies internal names only.** It was the only consumer of a public
name's private answer; the proxy's second authority already took that role away from it.
- **Public names leave every machine's hosts region** on the first push after the change.
Containers do not move with it: the roster is not part of a container's identity
([ADR 0148](0148-the-meshs-names-are-resolved-not-copied-into-containers.md)).
**How each is checked:**
- **The roster:** the controller's tests assert that the roster names the machines and nothing
else — a routed name in it, public or internal, fails the build.
- **On a machine:** asking the machine's resolver for a public name the mesh serves returns the
public address, and asking it for that route's internal name returns the private one. Asked from a
non-member on a LAN the resolver answers, the first must hold as well.
## References
- [ADR 0066 — public routing is name-agnostic](0066-public-routing-is-name-agnostic.md), whose
propagation clause this replaces.
- [ADR 0151 — a route's internal name is composed under the node that serves it](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md),
which made the private answer unnecessary.
- [Connectivity design §2 and §5](../03-DESIGN/01-to-be/08-connectivity.md), amended alongside this
record.
@@ -0,0 +1,126 @@
---
topic: what runs on it
status: accepted
date: 2026-10-03
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md
---
# 192. A tools bundle declares what it is given, and the runtime hands it to that bundle alone
## Context
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) put one
runtime on every node serving every module's tools from a bundle, and
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
said a module's own code is bundles and never an image. The two holders that moved first
([to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4) needed nothing a
bundle does not have: a fixed path, and root. Research
[020](../01-RESEARCH/020-what-a-bundled-tool-is-given/00-overview.md) measured the rest before
they move: thirty-three modules still run their tools as a container on the runtime's image, and
thirty-one of them are handed, through the container's environment and mounts, things a bundle
has no way to receive — the module's configuration file, its own secret as a file, the service's
address with the port the mesh chose, a provision's address, a directory of grants. Every one of
those is a file the mesh already places on the machine or a value the controller already composes
for the container, per module per machine, from references the manifest writes: a placed
directory, a chosen port. And the SDK's tool contributor is a function of an environment that the
runtime calls without one, so every bundle reads the process's four words.
Without a rule, each of the thirty-one would answer the question its own way, and the runtime's
process would be the one place where every module's paths meet.
> **Progressive insight — 2026-10-03.** The context above calls the thirty-one remaining containers
> tool containers handed what a bundle cannot receive. Measured the same day while building this
> record: nine of them run only tools; three run a main of their own; twenty import, beside their
> tools, the module's own long-running code — event handlers that subscribe on the bus and
> provisioners that act on grants — under the module's own bus identity, and some reach their
> service by a container network name or need a package the image installed. That code is not a
> tool and is not this record's to move: under
> [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
> §3 it is a `process` bundle, and how it is given its credential, its words and its reach is the
> open question of [design 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4c.
> Decision 4 applies to these containers' tools; the containers themselves go when their other
> code has moved. The decision and its options stand.
## Considered Options
1. **The bundle declares its environment on its artifact, and the mesh composes it as a
container's.** Chosen. The tools artifact gains `env`: names to values, the values written with
the references the composer already resolves for a container — `${dir:…}`, `${port:…}` — and
what was a mount target becomes the host path itself. The controller composes one environment
per bundle per machine into the runtime's declaration. The runtime hands it to that bundle's
contributor, or to the child it launches, and to nothing else. The tool code reads the names it
read before.
2. **The runtime derives it from the module's placed manifest** — a conventional word per
directory and port, no new field. Rejected: a convention the thirty-one tools must be rewritten
to, the runtime learning the composer's job, and a module that names its file one way and a
module that names it another needing different words regardless.
3. **The tool asks the controller over the bus.** Rejected: a tool that cannot start until the
bus answers fails in the one case tools exist for, and a secret crossing the bus to reach a file
already on the machine is a disclosure for nothing.
4. **Leave each module to its own device.** Rejected by the measurement: thirty-one modules, one
question.
## Decision
**1. A tools bundle says what it is given, on its artifact.** `build.artifacts[].env` names the
words the bundle reads and their values. A value is a path or a constant, composed with the
references a container's environment may use; **never a secret's content.** A secret reaches a
tool the way it reaches a container: as a file the mesh places, whose path the environment names.
A bundle that declares no `env` is given nothing beyond the runtime's own words, which is what the
two holders that moved have.
**2. The mesh composes it, per bundle per machine, as it composes a container's.** The same
references, resolved the same way, to the host's own paths. The composed environment travels in
the node's declaration beside the bundle's archive; a change to it is a change to the bundle for
the purpose of `restart-on`.
**3. The runtime hands each bundle its own environment, and nothing of another's.** A bundle
imported into the runtime's process receives it as the argument its contributor is written to
take; a bundle launched as a child ([ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md) §2)
receives it as the child's environment, over the runtime's own words. The runtime's process
environment is not where a module's words go, and a tool that reads the process's environment
rather than the one it was handed finds the runtime's four words and no module's.
**4. The remaining tool containers move in one change** after this is built, each proven by its
tools answering from the runtime, and the registration gate of
[to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP2 then refuses the
container shape for every module, as ADR 0188 already provides.
> **The mechanism changed — 2026-10-03, by ADR 0193.** Decision 3's imported path — the environment
> handed to an imported bundle's contributor — has nothing left to do: every served bundle is
> launched, and a launched bundle's environment is its own. The decision stands.
## Consequences
- The manifest gains one field on one artifact kind; the composer gains one more thing to resolve
with references it has; the runtime gains the hand-off and the separation. The thirty-one modules'
tool code does not change, and their conversion is the move of a container's `env` with its
mounts folded into host paths.
- A tool's inputs become legible in the manifest where its container hid them in mounts: what a
module's tools read is declared beside what the module writes.
- What got harder: the runtime must keep thirty-one environments apart in one process, and a
bundle's author must not reach for the process's environment. The separation is a rule the
runtime's test holds, not a property of the language.
- `MESH_BROKER_FILE` is not a bundle's to declare: the runtime speaks with the node's credential
([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)), and
a module's own bus credential went with its container.
## How it is checked
| Rule | Checked by |
|---|---|
| A value in a bundle's `env` is a path or a constant, never a secret's content | the catalogue's manifest check refuses a `${secret:…}` reference in a bundle's `env`, naming this record |
| The composer resolves a bundle's `env` as a container's | the controller's composition test: one module, one bundle with `${dir:…}` and `${port:…}` in its `env`, the declaration carrying the host paths and the chosen port |
| Each bundle sees its own environment and no other's | the runtime's test: two bundles with different `env`, loaded in one runtime, each answering with its own words and none of the other's; the same for a launched bundle |
| A change to a bundle's environment restarts the runtime | the composition test above, with `restart-on` naming the bundle |
| Live | a module whose tools read a configuration file and a token file answers from the runtime on one machine with no container |
## References
- [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
- Research [020](../01-RESEARCH/020-what-a-bundled-tool-is-given/00-overview.md) — the measurement
and the options
- [to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) — where the work is listed
@@ -0,0 +1,88 @@
---
topic: what runs on it
status: accepted
date: 2026-10-03
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md
---
# 193. Every bundle the runtime serves is launched, and the runtime knows no language
## Context
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
§2 made a served bundle a process that speaks MCP over stdio, and kept one exception: a TypeScript
bundle may be imported into the runtime's own process, "a shortcut over the same contract". Every
module's tools today take the shortcut, and it is where the day's defects came from:
- [Issue 209](../04-ISSUES/209-a-bundles-own-sdk-copy-registers-into-a-registry-the-runtime-never-reads/00-report.md):
an imported bundle's own copy of the SDK registered into a registry the runtime never read; fixed
by a resolve hook that redirects every bundle's SDK import to the runtime's copy.
- [ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md):
thirty modules' environments in one process had to be kept apart by an SDK change and runtime
bookkeeping, where a process of its own has an environment of its own by construction.
- One faulty module can block or crash every other module's tools on its node.
And the shortcut ties the runtime to Node.js: only a JavaScript runtime can import JavaScript. The
operator's direction on 2026-10-03: *a module's tools are written in any language and the builder
builds them; the runtime runs them all and announces them; it should be fully language agnostic,
and node-tools can be rewritten in Go.*
## Considered Options
1. **Keep the shortcut.** Rejected: it is the cause of the three defects above, and it pins the
runtime's language.
2. **Launch every served bundle; the runtime knows how to start each language** (`node` for a
`.js`, exec for a binary). Rejected: the runtime would hold a table of interpreters, and a
runtime in Go would carry Node.js's knowledge for nothing.
3. **Launch every served bundle, and the build makes each served entrypoint executable.** Chosen.
A compiled language's binary is executable already; for an interpreted one the toolchain writes
a launcher beside the entrypoint — for TypeScript, a file that imports the entrypoint and serves
what it registered over stdio, using the bundle's own SDK. The runtime execs what it is given.
## Decision
**1. Every bundle the node's runtime serves is a child process speaking MCP over stdio.** The
in-process shortcut of ADR 0188 §2 is withdrawn. Everything else ADR 0188 §2 says — `tools/list`,
`tools/call`, `<seat>.<verb>` naming a seat's verb, the runtime serving each on the bus — stands.
**2. The runtime knows no language.** It is given an executable per served entrypoint and starts
it, with that bundle's environment ([ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md))
over its own words, and the module it serves it as. What makes an entrypoint executable is the
toolchain's business: a binary is one; an interpreted language's toolchain writes a launcher.
**3. A launched bundle is told the module it serves as**, so that what it lists unprefixed is that
module's own tools and a seat's verbs are always `<seat>.<verb>`, whichever it registered first.
**4. The runtime may be written in any language.** Nothing it does needs it to share a language
with a bundle; the mesh's runtime moves to Go, against this contract, once the contract is proven
in the runtime that exists.
## Consequences
- A process per served module per node. On the busiest machine that is a score of small children
where there was one process; a Go bundle costs a fraction of a Node.js one.
- The SDK resolve hook (issue 209) and the per-registration environment hand-off (ADR 0192 §3,
imported bundles) have nothing left to do and go; a launched bundle's environment is its own.
- A module's TypeScript tool code does not change: it registers as before, and the generated
launcher serves what it registered.
- A bundle that crashes or hangs takes only its own tools down, and is started again on its next
call, as ADR 0188 already provides for a launched bundle.
## How it is checked
| Rule | Checked by |
|---|---|
| Every served bundle is launched | the runtime's tests: a TypeScript bundle and a bundle in a second language, both launched, both answering over a real bus; a non-executable entrypoint is refused by name |
| The runtime knows no language | the runtime holds no interpreter: it execs the path it is given (code review; the Go runtime has no Node.js dependency at all) |
| A TypeScript served entrypoint is executable | the builder's test: a TypeScript bundle's served entrypoint has a launcher beside it, mode 0755 |
| A seat's verbs are named as the seat's whichever registers first | the SDK's test: a bundle registering its seat first and its own tools second lists `<seat>.<verb>` and its own tools unprefixed |
| Live | the packet filter's and intrusion prevention's seat verbs and the moved tools answer from launched bundles on every machine |
## References
- [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md),
[ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md)
- [to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4d
@@ -0,0 +1,157 @@
---
topic: the tiers
status: accepted
date: 2026-10-03
deciders: jochen
reconstructed: false
supersedes-in-part:
- 0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
extends: 0191-the-meshs-resolver-holds-only-the-meshs-own-names.md
---
# 194. The mesh has one resolver, and every node asks it for the mesh's names
> **Narrowed, not replaced — 2026-10-03.** How a node asks is decided again by
> [ADR 0196](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md): every
> node and container asks `mesh-resolver` first and a public resolver only when it is silent. There is
> no `systemd-resolved` module and no runtime `dns` naming `mesh-resolver`, and step 2 of the migration
> reads as 0196 states it. Option 2 below was rejected for a laptop with its tunnel down resolving
> nothing; a public resolver listed second answers exactly then. The one resolver, its placement and
> the retirement of every per-node copy stand.
## Context
**Every node runs its own resolver and holds its own copy of the mesh's names.** On the production
mesh on 2026-10-03, each of the four nodes held `node-dns-resolver` with dnsmasq, fed on every push
with a zones file (one wildcard per node) and a region of `/etc/hosts` (the machines), and pointed
its own `/etc/resolv.conf` at itself. The controller computes the names once; four daemons then hold
four copies, each read in its own way.
**Every resolution fault found that day was a copy disagreeing with the truth, not the truth being
wrong:**
- **A copy read once.** dnsmasq reads `/etc/hosts` at start. After the controller stopped publishing
public names ([ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)), every node's
hosts file was right and every resolver still answered the mail server's public name with a
tunnel address, until each was restarted.
- **A copy beside other copies.** On the workstation, a name resolved to two addresses in rotation:
the mesh's region gave the tunnel address, and two lines the operator had written before the mesh
existed — one in `/etc/hosts`, one in a file the resolver also reads — gave the LAN address. A
comment beside one of them said to delete it once the mesh took over; nothing made that happen.
- **A copy that became somebody else's resolver.** The home server's resolver also answers its LAN
(a listen address added 2026-10-02), and the LAN's router hands that address out as the only DNS
server. Every phone and television on the LAN resolved through a mesh node's private copy, which is
how ADR 0191's outage reached them.
**And the overlay already has one centre.** Every node has exactly one tunnel peer — the anchor —
and routes the whole private range through it. Two nodes on the same LAN reach each other through
the anchor. So a name under `.internal` is only ever useful while the anchor is reachable: a resolver
anywhere else adds a copy without adding an answer anybody can use.
**What a node asks is already a separate role.** The connectivity design split *serving* (answers
the names) from *asking* (decides what the machine asks), because systemd-resolved cannot answer a
wildcard and can only route the mesh's suffix to something that can
([connectivity §2](../03-DESIGN/01-to-be/08-connectivity.md)). ADR 0121 kept them as two seats,
`node-dns-resolver` and `node-resolver-config`, both at node scope. No node runs systemd-resolved
today; each writes `/etc/resolv.conf` as a plain file pointing at its own dnsmasq.
## Considered Options
**1. Keep a resolver on every node, and make the copies more careful.** Restart on every file it
reads, own every file it reads, refuse to listen on a LAN. Each is a fix for one way a copy goes
stale, and the next way is not on the list yet. It keeps four answers to one question.
**2. One resolver for the mesh, and every node sends it every query.** The simplest asking side —
`resolv.conf` names the mesh's resolver and nothing else. Rejected: public resolution then depends on
the tunnel. A laptop whose tunnel is down could resolve nothing at all, and a public name would take
a detour through the anchor for no reason ADR 0191 left standing.
**3. One resolver for the mesh's names; each node asks it for those only.** The mesh's resolver holds
every node's internal domain. Each node's asking role routes the mesh's suffix to it and every other
name to public resolvers. Chosen.
## Decision
**The mesh has one resolver.** It is a module holding a new mesh-scoped seat, **`mesh-resolver`**
(capacity one). It holds each node's internal domain — `<node>.internal` and everything under it, at
that node's private address ([ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md))
— and listens on the private network only. It is placed on the node every tunnel converges on, so
that it shares the overlay's single point rather than adding one. Which daemon fills the seat stays
the module's business, as the connectivity design says.
**Every node asks it for the mesh's names and nothing else.** `node-resolver-config` routes the
mesh's suffix to `mesh-resolver` and leaves every other name with public resolvers.
**Why a stub on every node.** `resolv.conf` cannot route by domain: the C library asks the servers it
lists in order, for every name, and moves to the next only when one does not answer — an NXDOMAIN
from the first is final. Listing `mesh-resolver` first sends every public name through the tunnel
(option 2); listing a public resolver first means `.internal` is never asked of the mesh. Something on
the node has to look at the name before choosing a server, and that is a stub resolver. Keeping
dnsmasq for it would keep a daemon that reads hosts files and can be told to answer a LAN — the two
ways copies went wrong. systemd-resolved holds no names of its own, routes by domain natively (a
routing domain `~<suffix>` on the server that answers it), and is part of systemd, already installed
on every node and enabled on none.
**So the asking side is a `systemd-resolved` module**, claiming `node-resolver-config` — the same claim
as the `resolv-conf` module it replaces, so the mesh refuses both on one node. It enables the service,
writes its configuration (the mesh resolver for the suffix, public resolvers for everything else), and
writes `/etc/resolv.conf` as a file naming the stub — a file the module owns, not a link to one.
**A container asks the mesh's resolver directly.** The container runtime cannot use a loopback stub
and drops its routing domains, so the runtime's `dns` names `mesh-resolver`, which forwards public
names for the containers that ask it. This is the one place a public name passes through the mesh,
and it is stated rather than hidden.
**`node-dns-resolver` is retired**, and with it every per-node copy: the zones file, the mesh's region
of `/etc/hosts` (the floor connectivity §2 already planned to remove), and the daemon on every node
but the one holding `mesh-resolver`. This narrows ADR 0121's *"the-dns-port → node-dns-resolver"*:
the serving role keeps its distinction from the asking role and moves to mesh scope, as ADR 0121 did
for the private network.
**A LAN's resolver is not the mesh's.** No device that is not a member can reach a private address,
so no member's resolver answers a LAN on the mesh's behalf. A router that hands out a node's address
as a LAN's DNS server is pointed elsewhere before that node stops answering.
**The order is fixed, because every step before the last leaves a working resolver:**
1. `mesh-resolver` is assigned and answers on the private network.
2. Each node's `node-resolver-config` moves from `resolv-conf` to `systemd-resolved`, and the container
runtime's `dns` to `mesh-resolver`.
3. A LAN whose router points at a node's resolver is pointed at its router or a public resolver.
4. `node-dns-resolver` is unassigned from every node, and the hosts region is withdrawn.
## Consequences
- **One answer per name.** A name is wrong in one place or right everywhere; no node can hold a copy
that disagrees, and no operator file on a node is read by the mesh's resolver.
- **The anchor down means no `.internal` names** — which it already meant for `.internal` traffic,
since every tunnel goes through it. Public resolution on every node is unaffected.
- **A container's public resolution depends on the mesh's resolver.** Accepted, and named in the
decision; a container that must resolve public names with the tunnel down is the case it costs.
- **Every node runs systemd-resolved**, through the `systemd-resolved` module. It is installed
everywhere already and enabled nowhere; the mesh still ships no resolver of its own.
- **The runtime's `dns` changes once per node**, which the runtime reads only at start. With
`live-restore` already on, that restart keeps every container running.
- **A LAN loses a resolver it had borrowed.** The router change is an explicit step, done through
the module that manages the router, before the node's resolver goes.
**How each is checked:**
- **One holder:** the seat has capacity one, so a second assignment is refused by the controller.
- **Asking:** on each node, `resolvectl` shows the tunnel's link with `mesh-resolver` and the suffix as
its routing domain; a name under `.internal` is answered by it, and a public name is answered
without it (its query log shows no public name from a node).
- **No copies:** no node but the holder answers DNS on a private or LAN address — every other node's
port 53 is systemd-resolved's loopback stub and nothing else — and no node's `/etc/hosts` carries a
mesh region.
- **A LAN:** the router's DHCP DNS option names no node's address.
## References
- [ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md) — what the mesh's resolver
holds; this record decides where it runs and how nodes reach it.
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — the two
resolver seats, and the private network's move to mesh scope this mirrors.
- [Connectivity §2](../03-DESIGN/01-to-be/08-connectivity.md) — serving and asking as two roles;
amended alongside this record.
- [The seats](../03-DESIGN/01-to-be/26-the-seats.md) — the seat table, amended alongside.
@@ -0,0 +1,102 @@
---
topic: what runs on it
status: accepted
date: 2026-10-03
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
---
# 195. The mesh's tools are found by address, not announced whole
## Context
The console answers MCP on a machine's loopback ([ADR 0152](0152-the-operators-surface-is-a-module-the-console.md),
[to-be 34](../03-DESIGN/01-to-be/34-the-console.md)) and announces, at a session's start, every tool
the mesh can say it has: 228 on 2026-10-03, 110 KB, taken once. Three things are wrong with that,
measured in research [021](../01-RESEARCH/021-finding-a-tool-in-the-mesh/00-overview.md):
- **Size.** Only one client's habit of deferring long lists keeps them out of the model's context.
- **Ambiguity.** A module on two machines is listed once, `node` optional, *whichever answers* — for
postgres and mssql, whose instances hold different data, a call that names no machine asks an
arbitrary one.
- **Staleness.** A tool that arrives after the session started is not listed until it reconnects.
The mesh already has the structure a caller needs: seats held once for the mesh, seats held once per
machine ([ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)), and
modules assigned to machines, each assignment issued its own subjects
([ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)).
The operator's direction: *tools are discoverable, in layers, and asking novox's postgres is not asking
ace's.*
## Considered Options
1. Keep the flat list and rely on the client. Rejected: the ambiguity and the staleness stay, and it
is one client's behaviour.
2. One tool per assignment, the machine in the name. Rejected: the list multiplies, and an address in
a tool's name meets the API's limit — letters, digits, `_` and `-`, at most 64 characters.
3. **A fixed handful of tools that walk the mesh's structure, the address an argument.** Chosen.
4. MCP resources or prompts. Rejected: unevenly supported, and an agent acts through tools.
## Decision
**1. Everything the mesh answers has one address, by the layer it lives in:**
| layer | address | answered by |
|---|---|---|
| a seat held once for the mesh | `<seat>.<verb>` | that seat's holder |
| a seat held once per machine | `<node>/<seat>.<verb>` | that machine's holder |
| a module assigned to a machine | `<node>/<module>.<tool>` | that assignment |
| a module whose instances are interchangeable (ADR 0160) | `<module>.<tool>` as well | any of them |
**A call to a module that is not interchangeable names its machine, or is refused** naming the machines
it runs on. "Whichever answers" is no longer an answer for state a machine holds.
**2. The console announces a fixed set of tools, not the catalogue:**
- **`mesh_overview`** — the mesh's seats with their verbs, and its machines;
- **`mesh_machine`** — one machine: the node seats it holds and the modules assigned to it, each with
its tools by name;
- **`mesh_search`** — words in, matching addresses out with one line each, across every layer;
- **`mesh_describe`** — one address in, its description and argument schema out;
- **`mesh_call`** — an address and its arguments in, the answer out, with the machine that gave it.
Each is answered from the mesh when it is asked, so a tool that arrived a minute ago is found without
the client reconnecting. The names are the API's kind of name; addresses never have to be.
**3. The flat catalogue stays reachable, not announced:** the `mesh` client and a console setting can
still list it whole, for a person reading it or a client that wants it. An agent pointed at the console
sees the five.
> **The mechanism changed — 2026-10-03, by ADR 0197.** Where the console learns what exists: not
> from the catalogue's roster and the controller's printed lists, but from every runtime announcing
> itself on the bus in the NATS services protocol, checked against the controller's records read as
> JSON. The addresses and the five tools stand.
## Consequences
- An agent spends a call or two finding a tool it does not know, and none on one it does; the context
no longer carries 110 KB it mostly never uses.
- The ambiguity is closed by the address, not by a description asking the agent to remember `node`.
- What got harder: an agent that once saw a tool's schema up front now asks for it. `mesh_describe` and
`mesh_search` answering with the schema of a close match keep that to one call.
- The discovery verbs are the console's; the mesh's own records — seats, machines, assignments — are
the controller's, and the console asks it rather than keeping a copy.
## How it is checked
| Rule | Checked by |
|---|---|
| The console announces five tools | the console's test: `tools/list` answers exactly the five |
| An address resolves to one subject per layer | the console's tests: a mesh seat, a node seat, an assignment, an interchangeable module, each called by address over a real bus |
| A non-interchangeable module without a machine is refused, naming its machines | the same tests |
| A tool that arrives after the session started is found | a test registering a module after the console's first answer and finding it by `mesh_search` |
| Live | from a fresh session, *which databases does novox's postgres hold* is answered by novox's postgres, found through the five |
## References
- [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)
- Research [021](../01-RESEARCH/021-finding-a-tool-in-the-mesh/00-overview.md)
- [to-be 34](../03-DESIGN/01-to-be/34-the-console.md)
@@ -0,0 +1,98 @@
---
topic: the tiers
status: accepted
date: 2026-10-03
deciders: jochen
reconstructed: false
supersedes-in-part:
- 0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
---
# 196. A node asks the mesh's resolver first, and a public one only when it is silent
## Context
**[ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md) gave the
mesh one resolver and had each node ask it for the mesh's names only.** Because `resolv.conf` cannot
route by domain, that needed a stub on every node — a `systemd-resolved` module — and a separate
`dns` for the container runtime, which cannot use a loopback stub. It rejected the simpler shape,
every node sending every query to the mesh's resolver, on the grounds that *"a laptop whose tunnel is
down could resolve nothing at all."*
**That is true only of a `resolv.conf` naming the mesh's resolver alone.** The C library asks the
servers it lists in order and moves to the next when one does not answer within its timeout. A public
resolver listed second is asked exactly when the mesh's is unreachable — the anchor down, the tunnel
down, a laptop behind a captive portal that has not let the tunnel up — and never otherwise. An answer
from the first, including "no such name", is final, so `.internal` is never asked of a public resolver
while the mesh's answers.
**And the container runtime copies a machine's resolvers into its containers when they are not
loopback addresses.** With the mesh's resolver and a public one listed, every container gets both, as
they are, with nothing configured for the runtime.
## Considered Options
**1. Keep ADR 0194's stub.** Public names never touch the mesh, and a node with the anchor down
resolves public names at full speed. It costs a module and a running service on every node, a second
configuration for containers, and the one asymmetry ADR 0194 had to state — containers' public names
through the mesh, nodes' not.
**2. Every node asks the mesh's resolver for everything, with a public resolver as the silent
fallback.** One server answers every node and every container; nothing on a node routes, holds names,
or runs. Chosen.
## Decision
**A node's `/etc/resolv.conf` names the mesh's resolver first and a public resolver second, with a
short timeout and a single attempt.** It is written by the module holding `node-resolver-config` — the
existing `resolv-conf` — which now names `mesh-resolver`'s address instead of the machine's own. The
mesh's resolver answers the mesh's names from what it holds and forwards every other name, giving the
public answer ([ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md) is unchanged: no
public name gets a private answer).
**Containers take the same two resolvers from their machine.** The container runtime's own `dns`
setting is not written; the runtime copies the machine's non-loopback resolvers into every container.
**This replaces, from ADR 0194:** the asking side as a stub (*"So the asking side is a
`systemd-resolved` module"*), the container runtime's `dns` naming `mesh-resolver`, and step 2 of the
migration as written. There is no `systemd-resolved` module. Everything else in ADR 0194 stands — one
`mesh-resolver`, on the node every tunnel converges on, holding each node's internal domain, the
retirement of `node-dns-resolver` and every per-node copy, and a LAN's resolver not being the mesh's.
**The migration, as it now reads:**
1. `mesh-resolver` is assigned and answers on the private network.
2. Each node's `resolv-conf` names `mesh-resolver` first and a public resolver second.
3. A LAN whose router points at a node's resolver is pointed at its router.
4. `node-dns-resolver` is unassigned from every node, and the hosts region is withdrawn.
## Consequences
- **Every name a node or container asks goes through the anchor while it is up.** A public lookup
takes a few milliseconds longer than asking a public resolver directly, and the mesh's resolver sees
every name its nodes look up. It is the operator's own server.
- **With the anchor unreachable, each lookup waits out one timeout, then resolves publicly.**
`.internal` names fail then — as `.internal` traffic does, every tunnel going through the anchor.
- **A LAN is unaffected by this choice.** Devices that are not members never read a node's
`resolv.conf`; they get their resolver from their router, which step 3 points at itself.
- **Nothing new runs on a node.** No stub, no module, no per-node configuration for containers.
- **The runtime's `dns` key goes with `node-dns-resolver`.** The dnsmasq module wrote it into the
runtime's configuration; unassigning that module in step 4 withdraws it, and the runtime reads the
change only when it next starts — with `live-restore` on, that restart keeps every container
running.
**How each is checked:**
- **Order:** each node's `/etc/resolv.conf` lists `mesh-resolver`'s private address first and a public
resolver second, and nothing else.
- **Fallback:** with `mesh-resolver` unreachable from a node, a public name still resolves there, after
the timeout.
- **Containers:** a container started on a node lists the same two resolvers.
- **A LAN:** the router's DHCP DNS option names the router, not a node.
## References
- [ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md) — the one
resolver; this record replaces how nodes and containers ask it.
- [ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md) — what the resolver holds.
- [Connectivity §2](../03-DESIGN/01-to-be/08-connectivity.md), amended alongside this record.
@@ -0,0 +1,85 @@
---
topic: what runs on it
status: accepted
date: 2026-10-03
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md
---
# 197. Every tool announces itself on the bus, in the NATS services protocol
## Context
[ADR 0195](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md) gave every tool an
address and the console five tools to find them. Where the console learns what exists, it inherited
from [to-be 34](../03-DESIGN/01-to-be/34-the-console.md) §3: ask the catalogue for its roster, ask
each module on the roster for its `tools`, and read the machines and assignments from the
controller's printed `node list` and `module list`. Built that way on 2026-10-03, it worked and showed
what is wrong with it:
- **It asks what should exist and infers what does.** The roster holds every module the catalogue
ever registered; 47 of them were reported "not answering" on 2026-10-03, most with no tools at all
and several retired.
- **It parses prose.** Two of the controller's answers are text for a person, and a reworded column
breaks discovery.
The bus already knows what answers. NATS has a services protocol for exactly this: a service answers
`$SRV.PING` and `$SRV.INFO` — every instance, on one request — with its name, its instance, and every
endpoint's subject and metadata, in a published format the NATS tools read. The operator's direction:
*every tool announces itself; the mesh has the full picture, so nothing should be inferred or parsed.*
## Considered Options
1. **Keep asking the roster, and give the controller JSON answers.** Fixes the parsing, keeps the
inference.
2. **Re-serve every tool through a NATS services library.** The announcement for free, but every
runtime's serving path rewritten around a library, in two languages, for no change in behaviour.
3. **Every runtime answers the services protocol's discovery subjects with what it serves; serving
is unchanged.** Chosen.
## Decision
**1. What answers announces itself.** Every runtime that serves tools — each machine's tool runtime,
the per-module runtimes still in containers, and the controller for the seat it holds — answers
`$SRV.PING` and `$SRV.INFO` in the NATS services format: one service per module or seat it serves,
named for it, its instance the machine; one endpoint per tool, its subject and queue exactly as
served, its metadata the tool's description, argument schema, the machine, the seat and scope where
it is a seat's verb, and whether the module's instances are interchangeable.
**2. The console finds what exists by asking the bus,** one `$SRV.INFO` request, every answer
gathered for a short window. What it announces through ADR 0195's five tools is what answered.
**3. What should exist is the mesh's records, read as data.** The controller answers its machines and
modules as JSON, and says which modules declare tools; the console names as *not answering* only an
assignment that declares tools and did not announce them. A module with no tools is never listed.
**4. The grants say so.** Every principal that serves tools may subscribe the services discovery
subjects for what it serves; the console's and every runtime's account may publish the discovery
request. Replies travel to the asker's own inbox as every reply does.
## Consequences
- The standard `nats micro list` and `nats micro info` show the mesh's tools, live, to anybody holding
a credential — the bus's own view, not the mesh's description of it.
- Discovery costs one request and a gathering window, not one request per roster entry.
- What got harder: three runtimes must answer the same format the same way — the Go tool runtime, the
TypeScript runtime the containers still run, and the controller. The format is NATS's, so a test
reads all three with the NATS services client and nothing of the mesh's.
## How it is checked
| Rule | Checked by |
|---|---|
| A runtime announces exactly what it serves | each runtime's test: `$SRV.INFO` answered with one service per served module or seat, its endpoints' subjects the subjects served |
| The format is NATS's | the same tests read the answer with the NATS services client's own types |
| A module with no tools is never listed; an assignment with tools that did not answer is | the console's test, against controller records with both |
| Nothing is parsed from prose | the console reads only JSON answers (code review; the text parsers are deleted) |
| Live | `nats micro list` against the mesh's bus lists every machine's tool runtime and the controller |
## References
- [ADR 0195](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md),
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md),
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
- [to-be 34](../03-DESIGN/01-to-be/34-the-console.md)
@@ -0,0 +1,79 @@
---
topic: what runs on it
status: accepted
date: 2026-10-03
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md
---
# 198. A module's long-running code is launched by the node's runtime, and reaches the bus through it
## Context
[ADR 0193](0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md) made
every tools bundle a child the node's runtime launches, speaking MCP over stdio, and gave the channel
one bus verb: a tool's emit, published by the runtime as the module. Twenty-three modules still run the
rest of their own code — event handlers, provisioners, a preparation step, three mains — in a container
on the runtime's image, because that code needs what a container gave it: a bus connection that can
*subscribe*, and its module's words. Research [022](../01-RESEARCH/022-where-a-modules-long-running-code-runs/00-overview.md)
measured what it uses. [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
§1 says a module's own code is never an image.
## Considered Options
1. **The runtime launches it and is its bus.** Chosen.
2. A process per module with its own bus client and credential. Rejected for the reasons ADR 0188
rejected it for tools: a transport in every language's SDK, a credential per module on disk, and a
bus change rebuilding every module.
3. Keep the containers for it. Rejected: ADR 0188's rule stays broken for most of the catalogue.
## Decision
**1. A module's long-running code is a bundle the node's runtime launches and supervises,** exactly as
its tools are: an executable entrypoint, given the runtime's words and its module's
([ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md)),
started at the runtime's start and again when it exits. A bundle may serve tools, run long, or both.
**2. The runtime is its bus.** The stdio channel carries, beside MCP, the mesh's verbs a module's code
uses: `mesh/publish` (ADR 0193), **`mesh/subscribe`** — the runtime binds that module's durable
consumer, as the module's own runtime did, and delivers each event to the child as a `mesh/event`
request, acknowledging it on the bus only when the child has answered — and **`mesh/ask`**, a tool
call made on the module's behalf. The runtime's account is granted what each module it carries
consumes, and the consumer keeps the module's name, so no event is lost or replayed in the move.
**3. A preparation step is a run-once process the host runs before the runtime starts the module,**
with its module's words and no bus — what it already was.
**4. What a container reached by its network is reached on the machine.** A service by its published
port (`${port:…}`) on loopback; a backend's command-line client as a package of the machine's system,
or, where the system has none, the backend's own driver inside the bundle.
## Consequences
- The per-module containers go, and with them the runtime image as a way module code runs; ADR 0188's
registration rule can then refuse a module's own image without exception.
- One bus connection per machine carries every module's events; a module's handler is a function of
the events it is handed, in any language, with no bus client of its own.
- What got harder: the runtime holds every carried module's consumer and must not acknowledge an event
before the child has handled it — a child that dies mid-event leaves it unacknowledged, and it is
delivered again. The runtime's grants widen to what its modules consume.
- Two modules need code before they can move: their backends' clients exist on no machine's system,
so they talk to the backend through a driver instead.
## How it is checked
| Rule | Checked by |
|---|---|
| A subscribed event reaches the child and is acknowledged only after it answered | the runtime's test over a real bus: a child that answers is acknowledged once; one that dies mid-event is delivered again |
| The module's consumer keeps its name | the composer's test: the durable consumer the runtime binds is the one the module's own runtime bound |
| No module's own code is an image | the catalogue's registration check, without exception, once the last container has moved |
| Live | every moved module's provisioner and handlers act, on their machines, from the node's runtime; `docker ps` shows no runtime-image container on any machine |
## References
- [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md),
[ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md),
[ADR 0193](0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)
- Research [022](../01-RESEARCH/022-where-a-modules-long-running-code-runs/00-overview.md)
- [to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4c
@@ -0,0 +1,78 @@
---
topic: building it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0067-genesis-is-a-pivot.md
---
# 200. Genesis pivots to the controller as a container, and the first push hands it to a process
## Context
The controller is Go, compiled to one static binary, and is the last of the mesh's own programs a
machine runs from an image ([issue 213](../04-ISSUES/213-the-controller-is-a-go-program-run-in-a-container/00-report.md)).
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
§1 says a module's own code is bundles, never an image, and §3 that a service bundle is a `process` the
host runs. The handover exists: a process may name the container it `replaces`, and the host removes
that container only after the process has stayed up across two checks; two controllers are safe
together for that moment, the second standing by on the controller's consumers and every plan held by
one lock.
What stands in the way is genesis ([ADR 0067](0067-genesis-is-a-pivot.md)), which
[issue 223](../04-ISSUES/223-a-new-mesh-installs-its-controller-as-a-container/00-report.md) found
assumes an image and a container at every step from its third: it builds the controller's image,
starts a temporary controller from it, publishes it, finds the controller's container in the pivot
declaration, and from then on talks to the controller through it. A process's bundle is fetched from
the artifact store, and genesis raises the artifact store only after the pivot.
## Considered Options
1. **Raise the artifact store before the pivot**, publish the controller's bundle to it, and talk to
the controller from the host's side. Rejected for now: it reorders genesis around a store that is
itself a module the controller deploys, and rewrites the steps that talk to the controller — a
larger change to the one path that is exercised least, to remove a container that exists for
minutes.
2. **Pivot to the controller as a container, as today, and let the first push hand it over to the
process**, through the handover that already exists. Chosen.
3. **Keep the controller a container.** Rejected: it is the exception to ADR 0188 that every other
module's code has now left, and it costs a container runtime on the control machine and a
container recreation in the middle of a plan.
## Decision
**Genesis raises the controller as a container, under the resource the controller's process
`replaces`, and the first declaration the controller composes for its own machine hands it over.**
The container is genesis's own shape, built from the controller's repository, and is recorded on the
control machine exactly as the manifest's `replaces` names it, so the first apply after the pivot
finds a replacement for it and removes it once the process is up. The controller's manifest declares
only the process; the image form exists for genesis alone and is not a second way to run the
controller on a live mesh.
This is the one bounded exception to ADR 0188 §1: a module's own code in an image, for the minutes
between the pivot and the first push, on a mesh being created.
## Consequences
- A new mesh ends where a running one is: the controller a process, no controller container.
- Genesis keeps its steps; what changes is that it no longer reads the controller's container from the
manifest, and that it records the container under the name the handover expects.
- The controller's repository keeps its image build for genesis and the lab.
- The handover is now on genesis's path too: a process that fails to stay up leaves the genesis
container serving, and the apply says so — the same rule as on a live mesh.
## How it is checked
The installer's test raises a mesh whose controller manifest is the process form, and asserts that the
container genesis recorded is exactly what the process `replaces`, so the first apply hands over and
leaves one controller. Live, on the running mesh: after the manifest change is pushed, the control
machine runs the controller as a process and no controller container, and the controller's seat
answers throughout.
## References
- [Issue 213](../04-ISSUES/213-the-controller-is-a-go-program-run-in-a-container/00-report.md),
[issue 223](../04-ISSUES/223-a-new-mesh-installs-its-controller-as-a-container/00-report.md)
- mesh-host#86 (the handover), mesh-controller#252 (two controllers safe together),
mesh-controller#253 (the controller's manifest as a process)
@@ -0,0 +1,106 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md
---
# 201. A module keeps its current state in key-value buckets it declares, and reaches them through the runtime
## Context
A module's code reaches the bus through the node's runtime: it publishes events, subscribes to them
and asks tools ([ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)).
Events are kept for a week and replayed to a consumer that was away; requests are kept nowhere. What
neither gives is **the current value of something**, seen by every machine, including one that joins
after it was written. The first module to need it — the operator's agent on a machine — registers MCP
servers for every machine as events, and a machine assigned later never hears of them; and it would
replay a week of licence rotations where it needs only the binding that holds now. Research
[024](../01-RESEARCH/024-state-a-module-keeps-on-the-bus/00-overview.md) measured the alternatives and
the grants against a real server.
[Design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 already names *state* as one of the
mesh's relationships — 1:1, last per subject — and reserves it to the mesh's own declarations.
[Design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 expects key-value buckets on the bus.
## Considered Options
1. **Key-value buckets a module declares, created by the controller, reached through the runtime.**
Chosen.
2. **State as events on EVENTS, read last-per-subject.** Rejected: retention is per stream and EVENTS
keeps seven days, so a value unchanged for a week disappears; a second stream over the same subjects
is refused by the server (design 32 §3). And events give no get, list or delete.
3. **A last-per-subject stream per module, written by hand.** Rejected: it is what a key-value bucket
is on the server, without the client's get, list, delete and watch — the mesh writing NATS's
key-value layer again.
4. **State in a module's own files or database, shared by asking a tool.** Rejected for state every
machine must see: a machine joining later has to know whom to ask and poll, and an owner that is
down answers nothing — the property the bus exists to remove.
## Decision
**1. A module declares its state by name.** `state` names the buckets it owns, by local name; every
instance of the module may write and read them. `reads` names another module's bucket as
`<module>.<name>`, read-only. A bucket's options are its owner's: how many past values a key keeps,
and how long a value lives. A manifest names no bucket, stream or subject (design 32 §1).
**2. One bucket per module per name, mesh-wide.** A key may name a machine by the module's own
convention; the mesh does not scope buckets per machine.
**3. The controller creates the buckets, from the catalogue, on every raise** — from registration,
like a seat's stream, so a reader can watch a bucket whose owner is not yet assigned anywhere. A module
never creates one. The runtime's grant on each bucket is the union of what its carried modules may do:
an owner's instances write and read, a reader's read.
**4. Each assignment is issued its buckets in its membership** ([ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)),
by the name the module uses for each and whether it may write. The runtime serves `mesh/state.get`,
`put`, `delete`, `keys` and `watch` on the bundle's channel from that list, and refuses — with the
reason — a bucket the module was not issued and a write to one it only reads. A watch delivers the
current values first, without deletions, then an end-of-current marker, then every change, each as a
`mesh/state` request the bundle answers.
**5. No secret is stored in a bucket, sealed or not.** A bucket is a stream, and design 32 §10 keeps
every secret off streams. A value that needs a secret names it; the secret travels on request/reply.
**6. The mesh caps size; a bucket outlives its module.** One value per key and no expiry unless the
owner says otherwise; at most 256 KiB a value and 64 MiB a bucket. Unassigning a module leaves its
buckets and what is in them ([ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)); a bucket
whose declaration is gone from the catalogue is reported, never removed by the mesh.
## Consequences
- A machine that joins reads the current state at once, and every machine sees a change as it
happens, with no consumer created per reader and nothing replayed.
- The runtime's channel has a sixth verb family, and the SDKs a small state surface over it — a
contract, which ADR 0039 admits: it changes when the verbs do, rarely, and every module should be
rebuilt when it does.
- What got harder: the runtime must keep each module to its own buckets, because one principal per
machine carries all of them and the server enforces only the union. A write the server refuses
surfaces to a client as a timeout, not a refusal, so the runtime's own refusal is what a module sees.
- The secrets rule is only partly mechanical. Sealed values cannot be recognised; the runtime refuses
a value with a field whose name says it is a credential, which catches the ordinary mistake and not a
determined one. For the operator's agent this means an MCP server's authorisation header stays out of
its bucket.
- Buckets accumulate as modules come and go; that they are reported rather than removed is the price
of not deleting data.
## How it is checked
| Rule | Checked by |
|---|---|
| A manifest's state names are local, and a read names a bucket its owner declares | the catalogue's registration check, per manifest; a catalogue test that every `reads` whose owner is present names a bucket that owner declares |
| Buckets exist for every declared state | the controller's raise asserts them idempotently; its test over a real bus |
| Owners write, readers only read | the composer's test of the grants, per principal kind; the runtime's refusal test over a real bus |
| A watch hands current values first, without deletions, then changes | the runtime's test over a real bus |
| No credential-named field in a value | the runtime's refusal test |
| Live | one module puts on one machine and another machine's watch sees it; a machine assigned afterwards reads it at start |
## References
- Research [024](../01-RESEARCH/024-state-a-module-keeps-on-the-bus/00-overview.md)
- [Design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 and §10, [design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 and §3
- [ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md), [ADR 0039](0039-what-the-sdk-holds-and-refuses.md),
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md),
[ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)
@@ -7,7 +7,14 @@ reconstructed: false
extends: 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md extends: 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
--- ---
# 188. A provider declares what it derives for each consumer, and the mesh tells both ends # 202. A provider declares what it derives for each consumer, and the mesh tells both ends
> **Written as 0188 on 2026-10-02, renumbered to 0201, and to 0202 on 2026-10-04.** Twice, for the
> same reason twice: the bundles refactor took 0188 while this waited in a pull request, and the
> key-value-buckets record took 0201 while this waited again. Both times the number was free when
> it was chosen and taken by the time this merged. Only the number moved; the decision is the one
> taken on the 2nd. The check that refuses two records sharing a number is what caught it, both
> times — a number is how a record is cited, and three repositories cite this one.
## Context ## Context
@@ -90,6 +97,15 @@ a literal in a consumer's definition is not merely redundant — it is the one t
disagree with what the provider will actually create. The three object-store consumers lose their disagree with what the provider will actually create. The three object-store consumers lose their
hand-written bucket names in this change. hand-written bucket names in this change.
**5. A consumer that keeps several holders of one provision may not be served a derived value.**
Each holder gets its own login, `…_<local>` ([ADR 0094](0094-a-module-may-hold-several-secrets-from-one-provider.md)),
and a provider derives from the login — so it would make one resource per holder, while the
consumer's side has one binding and one `${bound:<provision>:<key>}`, both derived from the
un-suffixed identity. That is this record's own failure one case to the side, and just as quiet:
the consumer would authenticate and be refused on every object. Refused at resolution, naming
both ends. Lifting it means giving the consumer's side a local dimension, which is a decision and
not an omission.
## Consequences ## Consequences
- One more thing a definition may say, and one less thing a module may be wrong about. The - One more thing a definition may say, and one less thing a module may be wrong about. The
@@ -101,6 +117,10 @@ hand-written bucket names in this change.
- A provider that already serves consumers keeps serving them: the derived value equals what the - A provider that already serves consumers keeps serving them: the derived value equals what the
code derived, so no bucket, database or login changes name. This is a change of **who says it**, code derived, so no bucket, database or login changes name. This is a change of **who says it**,
not of **what is said**. not of **what is said**.
- A refusal here fails **that machine's push**, naming the definition, and nothing else. That is
deliberate and is the opposite of a module quietly left out: a definition that transcribes
somebody else's rule is wrong everywhere, not just here, and the loud failure is in front of
whoever can fix it.
- The mesh now holds a rule in another system's alphabet — one rule, `dns`, stated once. A second - The mesh now holds a rule in another system's alphabet — one rule, `dns`, stated once. A second
alphabet is a decision, not an addition: the cost of each is that the mesh must be right about alphabet is a decision, not an addition: the cost of each is that the mesh must be right about
somebody else's naming, and that cost is only worth paying where the mesh already mints the name. somebody else's naming, and that cost is only worth paying where the mesh already mints the name.
@@ -114,6 +134,8 @@ hand-written bucket names in this change.
consumer — one test asserting the three agree, because agreeing is the whole point. consumer — one test asserting the three agree, because agreeing is the whole point.
- Two consumers of one provider on one machine get two different derived values, and neither gets - Two consumers of one provider on one machine get two different derived values, and neither gets
the other's. the other's.
- A consumer with several holders of a deriving provider is refused, with both ends named — the
test asserts the refusal, not merely that something failed.
- A catalogue-wide test refuses a consumer definition that writes a literal where its provider - A catalogue-wide test refuses a consumer definition that writes a literal where its provider
derives: the provider's `serves` names the key, so the catalogue can say which definitions derives: the provider's `serves` names the key, so the catalogue can say which definitions
transcribe one. transcribe one.
@@ -0,0 +1,131 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
---
# 203. The account's environment is one module's, and every module contributes to it
## Context
A variable or a `PATH` entry is a fact about the operator's account. A toolchain needs its directory
on `PATH`, a version manager needs a variable naming its directory, an agent needs a variable that
turns one of its behaviours off, and the shell sets an editor. Today every one of these is a line of
one shell's syntax in one hand-written startup file. [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
measured on four machines:
- about a quarter of the 65 lines a workstation runs at shell start are environment;
- written into `.zshrc`, that environment reaches only interactive zsh. It misses the login shell's
`execute` verb ([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)),
every script, and every program a graphical session starts;
- the service manager's place for the account's environment, `~/.config/environment.d/`, holds
nothing on any machine.
The shell module as first written carried some of these lines in its own block and dropped the rest.
## Considered Options
1. **Each module writes its own lines into the shell's startup file.** Rejected: one shell's syntax,
read by one kind of start, and the same lines rewritten by every shell module.
2. **Contribute the facts to the login shell, whose holder renders them.** Rejected: the environment
then depends on which module holds the shell, every shell module renders the same facts again, and
the graphical session sees nothing.
3. **Option 2, and the service manager's holder renders the same facts a second time** into
`environment.d`. Rejected: one fact set with two owners, whose renderings can disagree, and a
duty for the service manager unrelated to managing services.
4. **One file in `environment.d` syntax, sourced by shells.** Rejected: that syntax is close to
POSIX assignment but not equal, and a value one reader accepts breaks the other.
5. **Shells read the service manager's environment generator.** Rejected: every shell start then
runs a process and depends on the service manager, and the output is unquoted.
6. **A module of its own holds the environment.** One mesh seat, held by one module per node,
whose files are the account's environment. Every module contributes facts to it, and those facts
are written in each reader's format. Chosen. It was the operator's proposal.
Within option 6, two ways to write the files:
- **a. The holder's own code renders what it receives.** This was research 025's starting position.
Rejected: the code needs something to run it whenever a contribution changes, and the result exists
only after a machine has applied and run it.
- **b. The controller renders the facts into the holder's files,** in two named formats, at
composition. Chosen. The result is in the declaration before any machine applies it, nothing has to
trigger anything, and the two formats are standards: POSIX shell assignment and the service
manager's `environment.d`. The controller learns no shell. It writes an assignment in a standard
syntax, as it already writes a fail2ban stanza a module supplied.
## Decision
**1. The environment is a node seat, `node-environment`, in the mesh's own set.** One module per node
holds it, and it is the only writer of the account's environment. The first holder is a module of its
own (working name `node-env`), with no package and no process.
**2. Any module contributes to it with `environment`:**
- **`variables`:** names and values. A name is a POSIX variable name and never `PATH`. A value is
literal; it may use `${machine:…}`, resolved first, and may not contain `$`, a quote, a backslash or
a line break. The only expansion is the mesh's own, so the two formats cannot read one value
differently.
- **`path`:** entries, each placed at the `start` or the `end` of the account's `PATH`.
**3. The holder places the rendered environment with two placeholders** in its own files:
- **`${environment:posix}`** renders lines a POSIX shell sources:
- every variable exported;
- every `PATH` entry added only if missing, so sourcing twice changes nothing.
- **`${environment:systemd}`** renders the same facts as the service manager's user environment, with
the account's existing `PATH` kept between the start and the end entries.
Each rendered line names the module that contributed it, so the file answers *where did this come
from*. Contributions are ordered by module name, and then in the order a module declared them.
**4. The seat's protocol fixes where the POSIX file is:** `~/.config/mesh/environment.sh` under the
account's home. A shell sources that path without knowing which module wrote it. The service
manager's file is `~/.config/environment.d/50-mesh.conf`.
**5. Refused at composition:**
- two modules on one node setting the same variable, both named;
- an environment placeholder in a module that does not claim `node-environment`.
A node with contributions and no holder writes them nowhere. The holder's absence is visible in the
node's assignments, and no contributor is refused for it, because a missing `PATH` entry is a gap, not
a broken machine.
## Consequences
- A shell's part in the environment is one line in its always-read startup file, sourcing the
POSIX file. A second shell module writes the same line in its own syntax, and no contributor
changes when the login shell does.
- The graphical session sees the same `PATH` as the terminal, from the same facts.
- The controller gains one gathered field and two renderers. Both are tested byte for byte, like
the jails a node composes ([to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)).
- What a person sets for themselves stays theirs: variables of their own sit in their own lines of
their shell's file, read after the mesh's.
- **What got harder:** a value that needs another variable expanded (`$HOME`, `$XDG_CONFIG_HOME`)
must be written with the mesh's own `${machine:…}` facts, or it is refused. Expansion at shell start
is exactly what made one value mean two things in two readers.
- Once [issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)
closes, a value a person varies becomes a setting of the module that contributes it
([ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
## How it is checked
| Rule | Checked by |
|---|---|
| Both renderings, byte for byte, from a fixed set of contributions | the controller's environment tests |
| Sourcing the POSIX rendering twice leaves `PATH` unchanged | the same tests, running `sh` over the rendering |
| A variable set by two modules is refused, naming both | the controller's resolve test |
| An environment placeholder outside the holder is refused | the catalogue check, which registration runs |
| A value with `$`, a quote, a backslash or a line break is refused | the manifest's parse test |
| The zsh holder sources the path the seat fixes | the catalogue's zsh test |
## References
- [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
[ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md),
[ADR 0177](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md),
[ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
@@ -0,0 +1,131 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
---
# 204. A module contributes shell code to the login shell in named slots, and the login shell is the mesh's seat
## Context
Some of what a shell runs at start is code in that shell's own syntax, and it belongs to other
modules:
- a prompt theme loads itself and its configuration;
- plugins load themselves;
- a version manager sources its loader.
Order matters: a prompt's instant-prompt cache must run first, and syntax highlighting last. The
predecessor kept all of this in one file per machine, and installed the theme and plugins by cloning
them in a hook.
[Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md) measured that file
as byte-identical on four machines. It carries:
- the shell's defaults;
- code belonging to four other pieces of software;
- a handful of the operator's own lines.
Nothing gave the other pieces a way in.
Two further facts bear on the seat itself:
- **The seat is declared by the zsh module** ([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
§1, under [ADR 0126](0126-a-module-declares-its-own-seats.md)). The controller refuses a second
module declaring a seat name, so fish or bash could only ever claim it, and the seat exists only
while zsh's definition is registered.
- **The kept region is the other way round.** [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
describes a kept region as a marked block holding the operator's lines. The host built the inverse:
the mesh's region is the marked block, and every byte outside it is kept, verified unchanged, and
given back when the module goes.
## Considered Options
1. **A drop-in directory** that each module places a file in, and the shell sources. Rejected: every
contributor names a path inside the shell module's territory
([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)); order becomes a naming
convention nothing checks; and nothing ties the file to the shell actually being the one it is
written for.
2. **Facts the holder renders**, through `contributes` / `receives`. Rejected: code is not a fact,
and the holder would only paste it.
3. **Code contributed for a named shell in a named slot, assembled by the controller into the
holder's file.** This is what the controller already does for fail2ban jails: each module supplies
text in the tool's own format, and the controller sorts and concatenates it into the holder's file
without interpreting it. Chosen.
For order, numbers (`10`, `50`, `90`) were rejected: every contributor guesses one, and collisions are
silent. **Three named slots** were chosen: `first`, `normal`, `last`.
## Decision
**1. `node-login-shell` is a node seat in the mesh's own set,** with the verb `execute`. It replaces
the module-declared `login-shell`. Everything else ADR 0176 decided stands: the holder sets the
account's login shell through the `user` shape, `execute` is the contract, and any node may call it.
A shell module claims the seat; none declares it.
**2. Any module contributes shell code with `shell`:** entries naming the shell they are for (`zsh`,
`bash`, `fish`), the slot, and the code. The controller does not read the code.
**3. The holder places the code with placeholders** in its own files: `${shell:<shell>:<slot>}`. Each
is filled with that shell's code for that slot, from every module on the node:
- ordered by module name;
- each piece preceded by a line naming its module;
- empty when nothing is contributed.
A shell-code placeholder in a module that does not claim `node-login-shell` is refused.
**4. The holder's duties, which are the seat's protocol:**
- Source the account's environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md))
from the startup file every start of that shell reads. For zsh that is `.zshenv`, which a script, a
login and `execute` all read.
- Write its interactive block at the **start** of the interactive startup file, so the operator's own
lines run after the mesh's and win.
- Run `execute` as a non-interactive login shell in the account's home:
- bounded below the runtime's call limit;
- its output bounded;
- its whole process group ended on timeout.
**5. The marked block is the mesh's; everything outside it is the operator's.** This is how ADR 0174's
"kept region" is built. That record keeps its decision and gains a note saying where the mechanism
lives.
## Consequences
- A prompt, a plugin and a version manager are each a module with its own package or archive, its own
configuration file, and a contribution. Assigning one adds its line to the shell, and unassigning it
takes the line away at the next composition.
- Assigning the shell module loses nothing the machine does today:
- what is common to every machine becomes the shell module's default or another module's
contribution;
- what is the operator's stays below the block.
- **The one-off migration is a person's act**, listed in the shell module's documentation
([ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)):
delete the lines the block now carries from the found file.
- `login-shell.execute` becomes `node-login-shell.execute`. Nothing has called it yet; the shell
module was never assigned.
- **What got harder:** a module wanting a line in the shell must say which shell and which slot, and a
module supporting three shells writes its code three times. That is the honest cost of code in
three syntaxes.
## How it is checked
| Rule | Checked by |
|---|---|
| Code lands in its slot, in module order, only for its shell | the controller's shell-contribution tests |
| A shell-code placeholder outside the holder is refused | the catalogue check |
| `node-login-shell` is the mesh's, and no module may declare it | the seat table's tests |
| The zsh block sits at the start, sources the environment from `.zshenv`, and holds the three slots | the catalogue's zsh test |
| `execute` is bounded in time and output and kills its process group | the zsh module's tool tests over real child processes |
## References
- [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
[ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md),
[ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
[to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
@@ -0,0 +1,69 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
---
# 205. Software the distribution does not package ships as a pinned archive of the module's own
## Context
The prompt theme the operator uses is not in the distribution's repositories. Its two plugins and an
autocomplete plugin are. The predecessor installed all four by running `git clone` against their
upstream repositories from an install hook. That way:
- the version on a machine was whatever upstream's default branch held the day the hook ran;
- two machines set up a week apart could differ;
- a machine with no route to upstream failed its install.
The mesh already has a pinned, delivered form for a module's own files: an **archive artifact** built
from a directory of the module's source, delivered by the artifact store, unpacked by the host's
`archive` resource, and pinned by digest. One showcase module uses it.
## Considered Options
1. **Clone from upstream on the machine,** as the predecessor did. Rejected: unpinned, unreproducible,
and it needs upstream reachable from every machine.
2. **Build from the distribution's user repository.** Rejected: the host installs packages from the
distribution's own repositories. A user-repository build is a toolchain on every machine for one
theme.
3. **Vendor a pinned upstream release into the module's directory and ship it as the module's archive
artifact.** Chosen. The release and its version are named in the module, its licence travels with
it, and every machine gets the same bytes from the mesh's own store.
## Decision
**A module whose software the distribution does not package carries a pinned upstream release in its
own source directory and ships it as an archive artifact.**
- The module's documentation names the upstream, the version and the licence.
- The host unpacks it with the `archive` resource into a directory the module owns.
- An upgrade is a change to the module, reviewed like any other.
Software the distribution *does* package is installed as a package; a vendored copy of it is
refused in review.
## Consequences
- The catalogue grows by the size of what it vendors: 1.4 MB for the prompt theme at the pinned
release.
- Upstream's security fixes reach a machine only when somebody updates the module. That is the same
trade every pinned dependency makes, and it is visible: the version is in the module.
- **What got harder:** a vendored program that downloads more at run time, as the prompt theme does
for its git status helper, still fetches that part from upstream on first use. This record pins
what the mesh ships, not what the software fetches for itself. The module's documentation says so.
## How it is checked
| Rule | Checked by |
|---|---|
| The archive is pinned by digest | the host's declaration validation, which refuses an archive without one |
| The upstream, version and licence are named | review of the module's documentation; the module's test asserts the licence file is in the archive |
| Packaged software is not vendored | review |
## References
- [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
@@ -0,0 +1,144 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
---
# 206. A node reports the Anthropic grant it holds; the licence manager adopts a licence by refreshing it, and what each node should hold is the manager's state
## Context
[ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
made the licence manager a module holding the `anthropic-licence-manager` seat: one rotation source, the
long-lived grants in its own store, a short-lived token handed to a node sealed on request/reply, the
agent module alone writing what the agent reads. How the manager *learns* a licence, and who starts each
exchange, it left to a later shape, and three texts have since disagreed: ADR 0183 has a node register
its key and the manager adopt a login only into a licence the node is already bound to; its dated note
of 2026-10-03 has the manager start every exchange and visit every node on a schedule; the agent module
as built asks the seat for its token when an event says to, and pushes a login to the seat.
**The operator settled it on 2026-10-04, in the operator's own words:** the manager must hold the active refresh token;
whichever node a login happened on holds the latest one; every client publishes what its credentials
file holds, the manager sees a licence it does not own yet and takes it into its store, and from then on
rotates it and distributes the access token. A manager launched for the first time holds no licence and
accepts what the clients report. Several nodes report the same account — today the nodes are all logged in
to one personal account — and before the manager adopts a grant it must know the refresh token still
works.
Two facts bound how that is built:
- **A refresh token cannot be published.** Anything published on the bus is kept, and a secret never
enters a stream, sealed or not ([design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10).
A module's state is a stream too, and the runtime refuses a value carrying a field named like a
credential ([ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md);
refused live on 2026-10-04 for an `Authorization` header).
- **A refresh token can only be checked by using it.** No endpoint answers "is this refresh token
valid" without exchanging it, and an exchange is presumed to rotate it (ADR 0183: the predecessor
lost a licence to a reused one). Checking and adopting are therefore one act, and whoever checks
becomes the token's only live holder.
Since ADR 0201 the bus has the shape this needs: **state** every node sees, including one that joins
later or a manager that starts later, read whole on start and then watched.
## Considered Options
1. **Each node publishes its credentials file, the token included.** What the operator described,
literally. Rejected for the token only: it would sit in a stream every principal that reads the
bucket can read, for as long as the bucket keeps it, and the runtime refuses it anyway.
2. **The manager visits every node on a schedule and collects a waiting login** (ADR 0183's dated
note). Rejected: the manager must know every node in advance and poll it, a node that joins later
waits for the next visit, and "what does each node hold" lives nowhere anyone can read.
3. **Each node reports what it holds as state, without the secret; the manager asks for the secret
only when the report shows a grant it does not hold, and adopts by refreshing.** Chosen: the
operator's flow, with the one part that cannot be on the bus moved onto request/reply.
## Decision
**1. Every agent module reports what its node holds, as its own state.** One key per node in the
module's `holdings` state: the account's identity as the agent's own state file names it (account id,
address, organisation), the kind, the refresh token's **fingerprint** and whether one is present at
all, the access token's fingerprint and expiry, the licence it was last handed, and when the credentials
file last changed. Written when the module starts — a node already logged in when the module is first
assigned reports at once — and again whenever the credentials file changes. **No token, ever**: a
fingerprint names a token without being one.
**2. A licence is an account, and the manager learns it from the reports.** The manager reads every
node's `holdings` at start and watches them. A report carrying a refresh token whose fingerprint the
manager does not hold is a **candidate**: for an account it has no licence for yet, a new licence; for
one it has, a login made since. A manager launched for the first time holds no licence and treats
every report as a candidate. An API key still enters only through the seat's `adopt` verb, from a file
on the manager's node.
**3. The secret travels only when asked for.** For a candidate, the manager calls that node's agent
module on request/reply, giving its own public key, and is answered with the grant sealed to that key
(ADR 0183's channel, unchanged).
**4. Adopting is refreshing.** The manager exchanges the candidate's refresh token at the vendor's
endpoint under its lease for that account. If the exchange succeeds, the grant it got back is the
licence's, stored encrypted, and the manager is from then on its only rotation source. If it fails, the
candidate is recorded dead, nothing is adopted, and the report says so. **Several nodes, one account:**
candidates for one account are tried newest login first; the first that refreshes is adopted, and the
manager does not exchange the others.
**5. A node holds an access token only, so the latest login wins.** A node bound to an adopted licence
is handed the access token and nothing else, and the agent module writes the credentials file without a
refresh token — so the agent on the node can never refresh it, and two refreshers never hold one grant.
A refresh token appearing in a node's file afterwards can therefore only be a person's login there; its
report makes it a candidate, and if it refreshes it replaces the licence's grant. That is the operator's
"whichever node a login happened on holds the latest one", made mechanical.
**6. What each consumer should hold is the manager's state.** One key per consumer in the manager's
`bindings` state: the licence, its kind, and a **generation** that increases with every rotation and
every switch. The agent module watches its own key; when the generation is newer than the one it
applied, it asks the seat's `current` verb for the token, sending its public key, and is answered sealed
(request/reply). A node that was away reads its key when it is back and asks once. The `licence.rotated`
and `licence.switched` events go: what they announced is now the state itself, and a node needs the
latest, not the history.
**7. A first binding follows the login.** When the manager adopts a licence from a node's report, a
node with no binding yet whose report names that account is bound to it. Every later change is a
person's act through `bind`, `switch` and `release`, as ADR 0183 says.
**8. The identity guard stands, on two sources.** The account a grant is filed under is the identity
the node read from the agent's own state. Where the vendor's answer to the refresh names the account,
the manager compares the two and refuses a mismatch with a notification; whether it names it is
measured when the manager is built, and the record of which source decided is kept in the audit.
## Consequences
- The manager needs no configuration to start: launched on a mesh whose nodes are logged in, it adopts
every account they hold, one licence each, from the newest login that still refreshes.
- Every node's holding is readable by anyone allowed to read the state — the console, an agent, the
operator — without a token in sight, which is what `licence_status` on each node answered one at a
time.
- **What got harder:** adoption consumes the refresh token the node held. On a node whose grant was
adopted, the agent's own copy is dead from that moment; until the manager hands it an access token
(decision 6), the agent keeps the access token it already had, which lives hours. And a node whose
file still holds a refresh token after adoption — it was not handed one yet — is a second holder of a
dead grant, not a live one, so the rotation-source rule holds.
- A candidate whose refresh fails is not retried by the manager: a dead refresh token does not come
back. A person logs in again, and the new report is a new candidate.
- Nothing in the reports is secret, but they do say which account each node uses; readers of the state
are declared in manifests like any other.
## How it is checked
| Rule | Checked by |
|---|---|
| No report carries a token | the runtime refuses a credential-named field (ADR 0201's test); the agent module's test: a report built from a full credentials file holds fingerprints and identity only |
| A node already logged in reports at start | the agent module's test: with a credentials file present and unchanged, starting writes its `holdings` key |
| A candidate is adopted only by a successful refresh, newest login first, once per account | the manager's tests against a stub vendor: two reports for one account, the newer refreshes and is adopted, the older is never exchanged; a failing refresh adopts nothing and records the candidate dead |
| A node is handed an access token only | the agent module's test: the file it writes after a hand-over holds no refresh token |
| A newer generation is fetched once, by request | the agent module's test: a `bindings` change with a newer generation asks `current` once; an equal one asks nothing |
| No event carries a token, and none announces a rotation any more | the manager's test of everything it publishes |
| Live | the manager launched with no licence on a mesh whose four nodes are logged in to one account adopts one licence, binds the four nodes, and each node's file then holds an access token and no refresh token |
## References
- [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the manager, its seat and its channel, which this extends
- [ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md) — module state, and the refusal of a secret in it
- [design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10 — no secret in a stream
- [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, amended by this record
@@ -0,0 +1,107 @@
---
topic: the mesh
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
---
# 207. A module depends on the node seats that apply its resources
## Context
A module declares resources: packages, services, containers, files. Some of those are applied
through software on the machine that is itself a module:
- a service through the service manager;
- a package through the package manager;
- a container through the container runtime.
Until now nothing said so. A module carried a *capability* such as `service-manager` or
`package-manager`, which the host detects on the machine. A capability says the software is
installed. It does not say that a module of the mesh holds the role and answers for it.
The cost showed on 2026-10-04:
- **A networking module declared the service manager's own package.** The controller allows one
declaration of a resource per node, so the module that *is* the service manager could not declare
its package and had been written without it. The networking module's real relation to the service
manager, that it needs one held on its node, was nowhere.
- **The container runtime** has had this decided for its own case since ADR 0165 and ADR 0166
(proposed): a module that delivers a container needs the runtime seat held on its machine, derived
from the container resource, with no manifest field.
- **The operator's order for building the machines' modules** (to-be 42) is *the most core first*.
That is an order the mesh should enforce, not one a person should remember.
## Considered Options
1. **Keep capabilities as the only gate.** Rejected: a capability is a fact about the machine, not
about the mesh. Software installed by hand satisfies it, and nothing then answers for it.
2. **A manifest field per module naming the seats it needs.** Rejected: a module would restate what
its resources already say, and a module that adds a service but forgets the field passes.
3. **Derive the dependency from the resources,** as ADR 0165 already does for containers, and refuse
an assignment whose seats are not held on the node. Chosen.
## Decision
**1. Three node seats apply resources,** each in the mesh's own set:
| resource | applied through | seat | first holder |
|---|---|---|---|
| `service` | the service manager | `node-service-manager` (ADR 0177) | `systemd` |
| `package` | the package manager | `node-package-manager` (new) | `pacman` |
| `container` | the container runtime | `node-container-runtime` (ADR 0166) | `docker` |
`node-package-manager` is new and has no verbs yet. `node-container-runtime` is seeded now as ADR 0166
names it. Its verbs, and the host creating containers through its holder, stay with that record's
acceptance.
**2. A module depends on each seat its resources need.** The controller derives this from the
resource types the module declares. A module never states it.
**3. A dependency is met when any module assigned to the same node holds the seat,** the module
itself included. The holders of these seats declare resources of each other's kinds: the service
manager's package needs the package manager, and the package manager's timer needs the service
manager. They are therefore judged as the node's whole set of assignments, never one at a time.
**4. Where it is checked:**
- **At `assign`,** an assignment whose dependencies are unmet by the node's assignments, including the
new one, is refused. The refusal names each seat and the modules in the catalogue that can hold it.
- **At composition,** an unmet dependency on a node is **reported** in `status` until the three holders
are assigned to every node. Then it is **refused** like any unresolved requirement. The switch is one
line in the controller, made when `status` reports none.
**5. The mesh's own foundation is exempt.** These are the pieces genesis lays before any module exists:
the host, the private network and the bootstrap runtime. Their declarations are the installation's,
not a module's.
## Consequences
- The order of to-be 42 becomes the mesh's: `systemd`, `pacman` and `docker` on a node before
anything that installs, runs or contains.
- **Two modules no longer declare one shared package to say they need it.** A component's module
(networkd's) declares what it configures and depends on the seat. The component's own package
belongs to the module that holds the seat.
- Capabilities stay what they are, facts about the machine, used where a module needs the machine to
be able to do something.
- **What got harder:** a module can no longer be tried on a node that lacks the core three. That is
the point.
## How it is checked
| Rule | Checked by |
|---|---|
| The dependency is derived from resources: a service, a package and a container each need their seat | the controller's resolve tests |
| A node whose assignments hold the seats passes; one missing a holder is refused at `assign`, naming the seat and its possible holders | the same tests, and `assign` live |
| Mutual dependencies among the holders resolve when they are assigned together | the same tests |
| Until the switch, an unmet dependency is reported in `status` and does not refuse a push | the controller's status test |
| Foundation declarations are exempt | the composition test with genesis's declarations |
## References
- [ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md),
[ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md),
[ADR 0177](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)
- [To-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)
@@ -0,0 +1,138 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
---
# 208. The graphical session is one module per piece, on the mesh's seats
## Context
The two workstations run one predecessor desktop
([research 026](../01-RESEARCH/026-the-graphical-session-as-modules/00-overview.md)). It was a module of
983 lines with four flavors and 92 theme variables. One of its machines carried another machine model's
hardware files, and its session's environment was a hand-kept copy of the account's. The operator asked
for the desktop as modules at the shell's level, consistent across machines, with sway as a sibling of i3.
To-be 38 named this WP7, and to-be 37 left one question for the resolver: how a module says it needs a
display server held on its node.
## Considered Options
- **One desktop module, as before** (research 026 §1, G1). Rejected: flavors again, and the evidence is
a flavor on the wrong machine.
- **One module per piece of software.** Chosen.
- **For "i3 needs an X server" (research 026 §3):**
- a new field (R2), rejected as a second way to say what provisions already say;
- assigning carefully (R3), rejected because that is the misassignment the evidence shows;
- **a provision with the machine's reach** (R1), chosen.
- **For other modules' lines in a holder's file (research 026 §5):**
- only drop-ins (C1), rejected because two files the session needs have no drop-in convention;
- only slots (C2), rejected as needless where the tool already reads a directory;
- **both, the boundary drawn by the tool** (C3), chosen.
## Decision
**1. One module per piece of software:** `lemurs`, `xorg`, `i3`, `xterm`, `picom`, `rofi`, `dmenu`,
`dunst`, the lock screen, `xclip`, the clipboard manager, `feh`, `i3status-rust`, a theme module,
`fonts`, `gnome-keyring`, and later `sway`, `foot`, `waybar` and `mako`. No flavors. What follows a
machine's hardware is that model's hardware module (research 027/03).
**2. The roles are node seats in the mesh's own set,** each with the verbs research 026/05 starts it with:
| seat | holders | verbs to start with |
|---|---|---|
| `node-login-manager` | lemurs | `sessions` |
| `node-display-server` | xorg, sway | `displays`, `layout` |
| `node-display-session` | i3, sway | `reload`, `workspaces`, `windows` |
| `node-terminal-emulator` | xterm, foot | `open` |
| `node-launcher` | rofi, dmenu | `menu`, the dmenu-compatible command |
| `node-notifier` | dunst, mako | `send`, `history` |
| `node-lock-screen` | the lock module, swaylock | `lock` |
| `node-clipboard` | the clipboard manager | `history`, `copy` |
| `node-bar`, `node-compositor` | i3status-rust, waybar; picom | none yet |
| `node-secret-service` | gnome-keyring | none yet |
A compositor that is its own server holds two seats, as sway does.
**3. A display is a provision with the machine's reach.**
- A display server provides `x11-display` or `wayland-display`, reachable only on its own machine.
- A module that draws on a display requires the one it speaks: i3, picom, xterm and the X lock require
`x11-display`; sway's companions require `wayland-display`.
- A requirement with the machine's reach is resolved on the requiring module's own node, or not at
all, and is refused naming the seat's holders.
- `xwayland`, as its own module, provides `x11-display` inside a Wayland session.
This answers to-be 37 §4 by reusing provisions and reach rather than a new field. A capability the host
reports, `graphical-session`, still gates the display server itself.
**4. Other modules contribute to a holder's file in the tool's own grain.**
- Where the tool reads a directory, the contributor places its own file there:
- i3's `include` directory;
- dunst's `dunstrc.d`;
- XDG autostart;
- `environment.d`;
- fontconfig's `conf.d`;
- ssh's `config.d`;
- the login manager's session directory.
- Where it does not, **ADR 0204's slots serve beyond shells.** A `shell` contribution's `for` may also
name:
- `xinitrc`: POSIX code the session's start runs;
- `xresources`: X resources merged at session start.
The holder of `node-display-server` places them with `${shell:xinitrc:<slot>}` and
`${shell:xresources:<slot>}`.
**5. The display server's module writes the session's start.** It writes a block at the start of
`~/.xinitrc`, in this order:
1. it sources the account's environment (ADR 0203);
2. it imports the session's own variables into the user manager and D-Bus activation, by an explicit
list;
3. it merges the X resources;
4. it runs the `xinitrc` slots;
5. it ends by starting the session holder's command, which the session module contributes in the
`last` slot.
The desktop's identity and the theme variables are environment contributions of the session and
theme modules. The hand-kept environment in today's file goes, and so does the predecessor's file of
secrets (research 027 question 2).
**6. Per-machine values:**
- Monitor layouts are profiles keyed by the monitors' identities (research 026/04). They are the
operator's data, and the display server's `layout` verb manages them.
- DPI and theme values are module defaults now, and settings after issue 168.
## Consequences
- A workstation's desktop is a list of assignments, the same on both. The one machine model's
hardware is one more assignment.
- The X stack is built first, and sway is designed in from the start.
- The controller learns:
- the eleven seats;
- provisions with the machine's reach;
- two more names for a contribution's `for`.
- **What got harder:** a module that draws must say which display it speaks, and one that wants both
ships twice.
## How it is checked
| Rule | Checked by |
|---|---|
| The seats are in the mesh's own set, refused to any module that declares them | the seat table's tests |
| A machine-reach requirement resolves on its own node only, refused naming the holders | the controller's resolve tests |
| `xinitrc` and `xresources` slots are placed only by the display server's holder | the catalogue check |
| The session block sources the environment, merges resources, runs the slots and ends with the session | the `xorg` module's manifest test, and on the proving workstation |
## References
- [Research 026](../01-RESEARCH/026-the-graphical-session-as-modules/00-overview.md), its 02, 04 and 05
- [ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
[ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
[ADR 0207](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)
- [To-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)
+23 -1
View File
@@ -188,7 +188,10 @@ python3 00-META/checks/index.py fail if stale
- **0185** — [A control plane behind its seat's row serves what it can](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md) - **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) - **0186** — [A ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang](0186-a-ban-list-never-holds-a-neighbour.md)
- **0187** — [A dead tracker is not the machine's failure](0187-a-dead-tracker-is-not-the-machines-failure.md) - **0187** — [A dead tracker is not the machine's failure](0187-a-dead-tracker-is-not-the-machines-failure.md)
- **0188** — [A provider declares what it derives for each consumer, and the mesh tells both ends](0188-a-provider-declares-what-it-derives-for-each-consumer.md) - **0189** — [The store keeps what the records name, and a maintenance step holds its writers still](0189-the-store-keeps-what-the-records-name.md)
- **0190** — [A seat's work is shared by its holders, and building is the first such role](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md)
- **0202** — [A provider declares what it derives for each consumer, and the mesh tells both ends](0202-a-provider-declares-what-it-derives-for-each-consumer.md)
- **0207** — [A module depends on the node seats that apply its resources](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)
### Its tiers, from the bottom up ### Its tiers, from the bottom up
@@ -222,6 +225,9 @@ python3 00-META/checks/index.py fail if stale
- **0126** — [A module declares its own seats; the mesh reserves its own](0126-a-module-declares-its-own-seats.md) - **0126** — [A module declares its own seats; the mesh reserves its own](0126-a-module-declares-its-own-seats.md)
- **0148** — [The mesh's names are resolved, not copied into every container](0148-the-meshs-names-are-resolved-not-copied-into-containers.md) - **0148** — [The mesh's names are resolved, not copied into every container](0148-the-meshs-names-are-resolved-not-copied-into-containers.md)
- **0151** — [A route's internal name is composed under the node that serves it](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md) - **0151** — [A route's internal name is composed under the node that serves it](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md)
- **0191** — [The mesh's resolver holds only the mesh's own names; a public name resolves publicly](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)
- **0194** — [The mesh has one resolver, and every node asks it for the mesh's names](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)
- **0196** — [A node asks the mesh's resolver first, and a public one only when it is silent](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md)
### What runs on them, and how it gets there ### What runs on them, and how it gets there
@@ -279,6 +285,9 @@ python3 00-META/checks/index.py fail if stale
- **0150** — [A module's own code runs as supervised processes under the module's one account](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md) - **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) - **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) - **0155** — [A definition names no installation: how that is checked, and the three ways a value that did gets out](0155-a-definition-names-no-installation-and-how-that-is-checked.md)
- **0164** — [A setting is declared with its default, its meaning and what changing it costs](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md) *(proposed)*
- **0165** — [`container-runtime` is what a machine can run; that a runtime is running is its holder's health](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md) *(proposed)*
- **0166** — [The container runtime is a node seat, and the host creates containers through its holder](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md) *(proposed)*
- **0173** — [The operator's machine is the mesh's, and a module is whatever it declares](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) - **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) - **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) - **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)
@@ -286,6 +295,18 @@ python3 00-META/checks/index.py fail if stale
- **0181** — [The operator account is a node fact, and a home is a placement root](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) - **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) - **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) - **0183** — [The Anthropic licence manager is a module holding a seat; it hands each node's agent its token over the bus, sealed; the controller and the host have no part](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
- **0188** — [A module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
- **0192** — [A tools bundle declares what it is given, and the runtime hands it to that bundle alone](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md)
- **0193** — [Every bundle the runtime serves is launched, and the runtime knows no language](0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)
- **0195** — [The mesh's tools are found by address, not announced whole](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)
- **0197** — [Every tool announces itself on the bus, in the NATS services protocol](0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)
- **0198** — [A module's long-running code is launched by the node's runtime, and reaches the bus through it](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)
- **0201** — [A module keeps its current state in key-value buckets it declares, and reaches them through the runtime](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)
- **0203** — [The account's environment is one module's, and every module contributes to it](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)
- **0204** — [A module contributes shell code to the login shell in named slots, and the login shell is the mesh's seat](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
- **0205** — [Software the distribution does not package ships as a pinned archive of the module's own](0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md)
- **0206** — [A node reports the Anthropic grant it holds; the licence manager adopts a licence by refreshing it, and what each node should hold is the manager's state](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)
- **0208** — [The graphical session is one module per piece, on the mesh's seats](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md)
### How it is built ### How it is built
@@ -308,6 +329,7 @@ python3 00-META/checks/index.py fail if stale
- **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) - **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) - **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) - **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)
- **0200** — [Genesis pivots to the controller as a container, and the first push hands it to a process](0200-genesis-pivots-to-the-controller-as-a-container-and-the-first-push-hands-it-to-a-process.md)
### How it is checked ### How it is checked
+71 -15
View File
@@ -5,10 +5,14 @@ code:
- mesh-controller internal/catalogue/filtering.go - mesh-controller internal/catalogue/filtering.go
- mesh-controller examples/route-proxy - mesh-controller examples/route-proxy
- mesh-controller internal/identity/authority.go - mesh-controller internal/identity/authority.go
- mesh-controller cmd/mesh-controller/plan.go (the names the roster publishes)
- mesh-host internal/identity/serving.go - mesh-host internal/identity/serving.go
- mesh-host internal/apply (the service that reflects a rule set) - mesh-host internal/apply (the service that reflects a rule set)
updated: 2026-10-02 updated: 2026-10-03
decisions: decisions:
- 02-DECISIONS/0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md
- 02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
- 02-DECISIONS/0191-the-meshs-resolver-holds-only-the-meshs-own-names.md
- 02-DECISIONS/0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md - 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/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/0169-a-machine-joins-through-the-tunnel-and-the-bus-is-never-public.md
@@ -288,8 +292,10 @@ expensively enough to be worth restating:
- **A node must not pin its own public name locally.** The duplicate record breaks resolution of - **A node must not pin its own public name locally.** The duplicate record breaks resolution of
that name for everything else that needs it. that name for everything else that needs it.
**What the host receives:** the resolver's configuration, as files, listing every peer's internal **What the host receives:** what to ask, not what to answer. The mesh has **one resolver**, holding
name and overlay address. every node's internal domain; a node asks it first and a public resolver only when it is silent
([ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md),
[ADR 0196](../../02-DECISIONS/0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md)).
**What goes away:** the `/etc/hosts` floor. It exists because a node had to reach the mesh **What goes away:** the `/etc/hosts` floor. It exists because a node had to reach the mesh
database before its own DNS existed; with [ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md) database before its own DNS existed; with [ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)
@@ -347,6 +353,33 @@ not a list of containers.
somebody starts by hand is not the mesh's to configure, and reaching into every container on a somebody starts by hand is not the mesh's to configure, and reaching into every container on a
machine — declared or not — is what a nameserver in `resolv.conf` would be for. machine — declared or not — is what a nameserver in `resolv.conf` would be for.
### One resolver for the mesh
*2026-10-03.* **The mesh's names live in one place: the module holding `mesh-resolver`**, a mesh-scoped
seat of capacity one, placed on the node every tunnel converges on. It holds one wildcard per node —
`<node>.internal` and everything under it — and listens on the private network only. It answers the
mesh's names from what it holds and forwards every other name, giving the public answer.
**Every node asks it for everything, and a public resolver only when it is silent.** The module
holding `node-resolver-config` writes `/etc/resolv.conf` naming `mesh-resolver` first and a public
resolver second, with a short timeout and one attempt: the C library moves to the second only when the
first does not answer — the anchor or the tunnel down, a captive portal holding the tunnel back — so
public names keep resolving then, and `.internal` is never asked of a public resolver while the mesh's
answers. Containers take the same two from their machine, the runtime copying non-loopback resolvers
into every container, so the runtime is given no `dns` of its own
([ADR 0196](../../02-DECISIONS/0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md),
replacing ADR 0194's per-node `systemd-resolved` stub).
**No node holds a copy.** The per-node resolver, its zones file and the mesh's region of `/etc/hosts`
go: every resolution fault found on 2026-10-03 was a copy disagreeing with the truth — a hosts file
read once at start, an operator's old line beside the mesh's, a node's resolver lent to a LAN. No
member's resolver answers a LAN; a router pointing at one is moved first. *Checked by each node's
`/etc/resolv.conf` naming `mesh-resolver` then a public resolver, by no node but the holder answering
DNS on any address, and by the router's DHCP DNS option naming the router.*
*What follows describes the per-node resolver this replaces — how it was built and why the roles were
split. The split stands; the serving role's scope is what moved.*
### The resolver, built ### The resolver, built
*2026-08-31.* **A service is reached at `<service>.<node>.internal`** — the first label is the *2026-08-31.* **A service is reached at `<service>.<node>.internal`** — the first label is the
@@ -421,7 +454,22 @@ that module and nothing else.
the argument for the table in ADR 0009 being a table: the pattern is only obvious once seen, and the argument for the table in ADR 0009 being a table: the pattern is only obvious once seen, and
the cost of not seeing it is inventing a mechanism that already exists. the cost of not seeing it is inventing a mechanism that already exists.
### And the public names a proxy serves must resolve in the mesh too ### The mesh resolves only its own names; a public name resolves publicly
**The mesh's resolver holds each node's internal domain and nothing else** — `<node>.internal` and
everything under it, so every route's internal name `<label>.<node>.internal` with no line of its own
([ADR 0151](../../02-DECISIONS/0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md)).
**A node's public domains — one or more — are never given a private answer**: it is forwarded and resolves to the
public address, from a member and from anything else the resolver answers — a resolver may serve a
machine's LAN, and a phone on that LAN must get the address it can reach
([ADR 0191](../../02-DECISIONS/0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)). Inside the
mesh, a routed service is reached, and certified by the internal authority, under its internal name.
*Checked by the controller's tests — the roster names the machines and no routed name —
and on a machine by asking its resolver for a public name the mesh serves: the answer is the public
address.*
*What follows is how the mesh got here, kept because the reasoning it rejects is the expensive half to
rediscover.*
*2026-09-09, found by an internal certificate authority that could not issue.* The mesh writes every *2026-09-09, found by an internal certificate authority that could not issue.* The mesh writes every
`<node>.internal` name into every declared container and treats the public names a proxy serves as a `<node>.internal` name into every declared container and treats the public names a proxy serves as a
@@ -444,6 +492,13 @@ would go stale the day one changes. The mesh propagates the names it was told to
knows nothing about what they mean knows nothing about what they mean
([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)). ([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)).
*2026-10-03, withdrawn.* Publishing public names with private answers turned every resolver that also
serves a LAN into an outage for that LAN's non-members — a phone was handed the control-node's tunnel
address for the mail server — while every check, run from a member, passed. Its reason had gone: routes
have internal names since ADR 0151, and the proxy certifies public names from a public authority and
internal names from the internal one. Superseded by the rule at the head of this section
([ADR 0191](../../02-DECISIONS/0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)).
## 3 — Exposure ## 3 — Exposure
Settled by [ADR 0007](../../02-DECISIONS/0007-connectivity.md); summarised here because Settled by [ADR 0007](../../02-DECISIONS/0007-connectivity.md); summarised here because
@@ -871,13 +926,15 @@ is unchanged. **The same code path certifies against an internal authority as ag
only the issuer differs.** That is what makes trusted certificates possible for a mesh whose names only the issuer differs.** That is what makes trusted certificates possible for a mesh whose names
the public internet cannot resolve. the public internet cannot resolve.
**And it does not work until the routed name resolves inside the mesh** — the §2 finding above, **The internal authority certifies internal names; a public one certifies public names.** The
arriving here because this is what needed it. The authority's challenge reaches the routed name only authority's challenge reaches the name it certifies, so each certifies what it can resolve: the
once that name is in internal resolution; a public authority is handed that dependency by public internal authority a route's `<label>.<node>.internal`, which the mesh resolves, and a public authority
DNS, and an internal one has to be handed it by the mesh. *Checked by a handshake to a routed name the public name, which public DNS resolves. A proxy holds both, and a public name is never certified
that verifies against the internal root and nothing else — which cannot succeed unless the issuer by the internal authority. *Checked by a handshake to a route's internal name that verifies against
first reached the name to certify it* the internal root and nothing else, and one to its public name that verifies against the public
([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)). roots* ([ADR 0191](../../02-DECISIONS/0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)).
Until 2026-10-03 this paragraph had the internal authority certify public names, which needed them
resolved inside the mesh ([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)).
## 6 — One statement behind exposure, filtering and certificates ## 6 — One statement behind exposure, filtering and certificates
@@ -962,6 +1019,9 @@ The list is worth having in one place, because it is most of the argument:
## Open ## Open
- **One resolver ([ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)).** Not built: every node still runs
`node-dns-resolver`. The migration's four steps are in the record, in order.
- ~~**What happens when the hub is down.**~~ **Resolved** by - ~~**What happens when the hub is down.**~~ **Resolved** by
[ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md), together with `06`'s [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md), together with `06`'s
matching question — they were one question. Nothing takes over. WireGuard has no failover, the matching question — they were one question. Nothing takes over. WireGuard has no failover, the
@@ -983,10 +1043,6 @@ The list is worth having in one place, because it is most of the argument:
operator's to move between meshes, but the manifest layer still stores it as a literal — so today operator's to move between meshes, but the manifest layer still stores it as a literal — so today
the composition is a per-node override rather than the design. The interpolation that would let a the composition is a per-node override rather than the design. The interpolation that would let a
module carry a label and a node carry the domain, and the mesh join them, does not yet exist. module carry a label and a node carry the domain, and the mesh join them, does not yet exist.
- **Publishing route names into internal resolution.** The same ADR requires a granted route to be
resolvable inside the mesh, not only routable from outside it; the mechanism that writes
`<node>.internal` into containers does not yet also write the routed names, which is why an
internal issuer cannot currently validate one without a hand-placed entry.
## The hub adopts the predecessor's tunnel ## The hub adopts the predecessor's tunnel
+65 -2
View File
@@ -4,10 +4,12 @@ status: proposed
code: code:
- mesh-controller cmd/mesh-builder - mesh-controller cmd/mesh-builder
- mesh-controller internal/builder - mesh-controller internal/builder
- mesh-catalog modules/builder - mesh-catalog modules/build-agent
updated: 2026-10-01 updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md
- 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md - 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
- 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
- 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md - 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
- 02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md - 02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md
- 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md
@@ -268,6 +270,29 @@ ships one and wrong for code the mesh built, which has no unit until the mesh wr
**Tools, hooks and consumers are not further modes**, which is the test of whether three is the **Tools, hooks and consumers are not further modes**, which is the test of whether three is the
right number: they are loaded by a tool host, and a tool host is a process that stays up. right number: they are loaded by a tool host, and a tool host is a process that stays up.
## Where a build runs
**On whichever machine holding the build role is idle** ([ADR 0190](../../02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md)).
The role is `node-build-agent`, a node seat; its holder is the `build-agent` module, assignable to
every machine with a container runtime. The controller asks the role, never a machine: a tier's asks go
onto the seat's one work queue together, and each holder pulls one at a time when it is idle, so a
tier of many images is built by as many machines as hold the seat and are online, and a machine that
is off builds nothing and blocks nothing. What a holding machine needs is what the builder always
needed — a container runtime, the artifact store and the package registry as provisions, a workspace,
the bus credential — said once in the module's manifest. The outcome names the machine that built it.
This is the bus's shared-work pattern, not a build-specific one: any module declaring a node seat with
`accepts` has its work shared by its holders the same way. Building is the first use.
*Built and proven live 2026-10-03.* `build-agent` holds `node-build-agent` on all four machines; the first
build taken by a workstation's agent was a catalogue module at 09:35 UTC; the one-holder `builder` is
retired. The switch found five gaps, each an issue: a worker whose type changed stranded its holder
([206](../../04-ISSUES/206-a-seats-worker-changing-type-strands-the-holder-and-the-build-that-would-fix-it/00-report.md)),
a re-made worker replayed the stream's history ([207](../../04-ISSUES/207-a-re-made-worker-replayed-every-ask-the-stream-kept/00-report.md)),
a seat's worker is made only when the controller starts ([208](../../04-ISSUES/208-a-seats-worker-is-made-only-when-the-controller-starts/00-report.md)),
a module's identifier must fit the tightest backend's key (the slug), and an idle machine's empty fetch
was read as the end (fixed in the controller the same day).
## A build says what it does, as it happens ## A build says what it does, as it happens
*2026-10-01 — [ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md).* *2026-10-01 — [ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md).*
@@ -293,6 +318,44 @@ build's lines reach a reader of its subject in order and the stream holds them a
against a real server); the seat verb with an id reads the log (controller test); and, live, a build against a real server); the seat verb with an id reads the log (controller test); and, live, a build
after the roll-out read line by line through the console. after the roll-out read line by line through the console.
## The store keeps what the records name
*2026-10-02 — [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md),
[issue 108](../../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md).*
Every build pushes another layer set and, until this, nothing ever removed one. The registry's own
answer — collect what no tag names — is wrong for this mesh: each artifact is pushed under one
moving tag and machines are pinned by digest, so every build but the newest is untagged and some
machine may still be running it.
**The mesh decides and the store reclaims.** Deletion is enabled on the store's one door — that
door already accepts a push, and a writer who can push can replace any tag, so delete takes
nothing a push did not already have, and ADR 0082's bargain (a store every machine reaches with no
credential to distribute first) is kept. The mesh then removes what it put there and no longer
keeps, **naming it from its own build records** rather than enumerating the store: it has never
put anything there it did not record, so a digest it did not record making is never named, which
is what keeps the sweep away from the images genesis pushed before any record existed.
An artifact stays for one of two reasons and otherwise goes: a definition the mesh holds names it
(no age limit — this is the floor), or it belongs to one of the five most recent successful builds
of its module (somewhere for a wrong release to return to). The sweep runs after a build the mesh
recorded, which is the moment new bytes landed and the moment the keep set moved; it needs no
timer. Deleting a manifest frees no bytes, so the store's own collector runs nightly as a
scheduled step with the server held still — which is what `while-stopped` exists for
([design 20](20-writing-a-module.md)). Plain collection, not `--delete-untagged`: what the mesh
keeps is still a manifest and so still referenced, and the dangerous flag is not needed once the
mesh is the one deciding.
A machine behind by more than five builds of a module, recreating a container, cannot pull what it
was running. It is already a machine the mesh reports as behind, and the answer is the current
declaration.
*How it is checked:* the keep set, against records, holds what a manifest names and the five most
recent builds and nothing else; a reference the mesh never recorded is never in the delete set; an
image and an archive are asked for at their own endpoints; a store with deletion off names the
remedy rather than the status code; a store that does not have it is recorded collected rather
than retried for ever. Live: the store's size before and after the first nightly collection.
## The builder compiles the languages the mesh is written in ## The builder compiles the languages the mesh is written in
*2026-09-29 — *2026-09-29 —
+26 -1
View File
@@ -5,11 +5,12 @@ code:
- mesh-catalog modules/showcase - mesh-catalog modules/showcase
- mesh-controller internal/builder - mesh-controller internal/builder
- mesh-sdk src - mesh-sdk src
updated: 2026-09-30 updated: 2026-10-02
decisions: decisions:
- 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md - 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
- 02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md - 02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md
- 02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md - 02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md
- 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
- 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md - 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md
- 02-DECISIONS/0040-what-a-module-is.md - 02-DECISIONS/0040-what-a-module-is.md
- 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md - 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md
@@ -208,3 +209,27 @@ is recreated with the new fact
([ADR 0099](../../02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md)). *How it is ([ADR 0099](../../02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md)). *How it is
checked:* the host's unit tests run a step again when its named file changed and not otherwise, checked:* the host's unit tests run a step again when its named file changed and not otherwise,
and recreate a container naming a step after the step ran. and recreate a container naming a step after the step ran.
## A recurring step may hold its own module's containers still
*Written 2026-10-02, from [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)
and [issue 108](../../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md).*
Some work cannot be done underneath a running service: an artifact store's collector walks the
storage and requires every writer stopped. A `run-once` step runs *beside* containers and a
scheduled one is the same container again, so until this a module had no way to say it — and the
mesh inherited a store that has never collected anything, because the predecessor said it with a
shell script and a script beside a module is not a resource in it.
A scheduled step may name `while-stopped`: resource ids of **its own module's** containers, which
the host stops before the run and starts again after it, in the reverse order, **whatever the step
did**. Three boundaries, each refused where it can be seen earliest — its own module's containers
only, because a module that could quiesce a neighbour could stop the mesh; scheduled steps only,
because at apply the declaration is applied in order and a step already gates what follows, so a
one-time offline job says *before* rather than *instead of*; and restoring that is not conditional
on anything, because the only real risk of the field is a window that never closes.
*How it is checked:* the host's unit tests assert stop–run–start in that order, the restart after a
step that **failed**, the reverse order for several containers, and a service left down said
loudly. The controller refuses, from the definition alone, a window with no schedule, one on a
run-once step, one naming a container the module does not declare, and one naming itself.
+23 -2
View File
@@ -7,8 +7,10 @@ code:
- mesh-tools src/broker-amqp.ts (to be replaced) - mesh-tools src/broker-amqp.ts (to be replaced)
- mesh-catalog modules/nats (to be written) - mesh-catalog modules/nats (to be written)
- mesh-sdk src (the protocol's NATS binding, step 3) - mesh-sdk src (the protocol's NATS binding, step 3)
updated: 2026-10-02 - mesh-tools node-tools/internal/bus (a module's state, ADR 0201)
updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
- 02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md - 02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md - 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
- 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md - 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
@@ -48,6 +50,7 @@ mesh's own state lives, and where what a module may say is decided by what it de
| **events** — a module says something happened | 1:many | delivered to every consumer that declared it; dead-lettered when it cannot be | | **events** — a module says something happened | 1:many | delivered to every consumer that declared it; dead-lettered when it cannot be |
| **tools** — a module or a person asks another's tool | request/reply | one answer, from one server, or a timeout | | **tools** — a module or a person asks another's tool | request/reply | one answer, from one server, or a timeout |
| **work to a role** — a module submits to a capability without knowing who provides it | job | exactly one holder does it; it queues while nobody does | | **work to a role** — a module submits to a capability without knowing who provides it | job | exactly one holder does it; it queues while nobody does |
| **state** — a module's current value of something, every machine reading it | key-value | the newest per key, kept until replaced or deleted; read whole by a machine that joins later |
The last two rows are the ones worth dwelling on, because they are not messaging in the sense of The last two rows are the ones worth dwelling on, because they are not messaging in the sense of
carrying bytes from A to B. **A role is addressable**, so a caller names the capability and never carrying bytes from A to B. **A role is addressable**, so a caller names the capability and never
@@ -56,7 +59,8 @@ changing ([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)
property of the mesh's architecture that happens to be expressed in subjects. property of the mesh's architecture that happens to be expressed in subjects.
And more of the mesh lands here as it is built: conditions and observed state in key-value And more of the mesh lands here as it is built: conditions and observed state in key-value
buckets that anything may watch, the server's own advisories becoming observations like any other buckets that anything may watch — the first of them a module's own declared state, *2026-10-04*
([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)) — the server's own advisories becoming observations like any other
([research 017](../../01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md)), and a person's ([research 017](../../01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md)), and a person's
client speaking the bus directly rather than through a surface built over it (§7). None of that client speaking the bus directly rather than through a surface built over it (§7). None of that
is a message being moved; all of it is the bus being the mesh's centre. is a message being moved; all of it is the bus being the mesh's centre.
@@ -84,8 +88,16 @@ mesh.seat.<seat>.event.<verb> a role's own event (JetStream: EVENT
mesh.seat.<seat>.tool.<verb> a role's tool (core request/reply) mesh.seat.<seat>.tool.<verb> a role's tool (core request/reply)
mesh.ask.<node>.<command> the controller's command api (core request/reply) mesh.ask.<node>.<command> the controller's command api (core request/reply)
mesh.assignment.<node>.<module> an assignment's membership (JetStream: ASSIGNMENTS, last-per-subject) mesh.assignment.<node>.<module> an assignment's membership (JetStream: ASSIGNMENTS, last-per-subject)
$KV.<module>_<name>.<key> a module's state (JetStream: a key-value bucket per declared name)
``` ```
**Added 2026-10-04** ([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)):
the last row is outside `mesh.` on purpose. A key-value bucket is NATS's own construct and lives
under NATS's own prefix, which is what lets the server's key-value layer — direct reads, rollups,
delete markers, watches — do the work instead of the mesh writing it again. The bucket is named for
the module and the local name joined by an underscore, which neither may contain, so two modules can
never derive one bucket.
**Revised 2026-10-01** ([ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)): the rows above **Revised 2026-10-01** ([ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)): the rows above
for a module's and a seat's tools are the shapes the controller *issues*, not rules a runtime carries. for a module's and a seat's tools are the shapes the controller *issues*, not rules a runtime carries.
Every assignment is published a membership — what it serves and where, in which queue, its seat verbs, Every assignment is published a membership — what it serves and where, in which queue, its seat verbs,
@@ -155,6 +167,7 @@ Core NATS is at-most-once. Everything the mesh must not lose lives in a JetStrea
| CONTROL | `mesh.control.>` except `alive` (a build's outcome moved to its seat, ADR 0121) | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped | | CONTROL | `mesh.control.>` except `alive` (a build's outcome moved to its seat, ADR 0121) | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped |
| NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest | | NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest |
| EVENTS | `mesh.mod.*.event.>` and `mesh.seat.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream). *2026-10-01:* a build's whole log is here too, as the build-machine seat's `log.<build id>` events ([ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)) — one subject per build, a week of retention, read back by `builds --log <id>` with a consumer that is gone when the reading is done | | EVENTS | `mesh.mod.*.event.>` and `mesh.seat.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream). *2026-10-01:* a build's whole log is here too, as the build-machine seat's `log.<build id>` events ([ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)) — one subject per build, a week of retention, read back by `builds --log <id>` with a consumer that is gone when the reading is done |
| `KV_<module>_<name>` | `$KV.<module>_<name>.>` | the newest value per key — as many past values as the owner declared — no age unless the owner declared one; a value at most 256 KiB, a bucket at most 64 MiB | a module's state (ADR 0201): one per name in a manifest's `state`, created from the catalogue on every raise, so it exists before its owner runs anywhere; kept when the module is unassigned, because what it holds is data |
Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool
call is a timeout the caller already handles. call is a timeout the caller already handles.
@@ -234,6 +247,14 @@ expresses this exactly, per subject, and better than a vhost could:
permissions for each consumed event's subject, its tool subjects, and that same inbox prefix. permissions for each consumed event's subject, its tool subjects, and that same inbox prefix.
Nothing else. A module that tries to publish outside its emits is refused by the server, not by Nothing else. A module that tries to publish outside its emits is refused by the server, not by
convention. convention.
- **A module's state** (ADR 0201), for whichever principal carries the module — today the machine's
runtime, whose grant is the union of its modules': binding to the bucket, reading a key directly,
and an ordered consumer for listing and watching, created and deleted on the bucket's own stream
and nothing else's; and, for the owner's instances only, publishing under the bucket's own
`$KV.<bucket>.>`. *Measured 2026-10-04 against a running server:* without the consumer-delete
grant a watch cannot be stopped cleanly, and a write the server refuses reaches the writer as a
timeout rather than a refusal — so the runtime refuses first, from the membership, and the grant
is the second line.
- **The controller's user** owns `mesh.control.>`, `mesh.node.>` and the streams, and may submit work - **The controller's user** owns `mesh.control.>`, `mesh.node.>` and the streams, and may submit work
to the seats the mesh's own flows use — a build, for one (ADR 0121). to the seats the mesh's own flows use — a build, for one (ADR 0121).
**A host's user** may publish its own `mesh.control.<node>.>` and subscribe its own **A host's user** may publish its own `mesh.control.<node>.>` and subscribe its own
+5 -3
View File
@@ -1,6 +1,6 @@
--- ---
layer: to-be layer: to-be
status: implemented status: in-progress
code: code:
- mesh-controller internal/catalogue/seats.go - mesh-controller internal/catalogue/seats.go
- mesh-controller internal/catalogue/resolve.go - mesh-controller internal/catalogue/resolve.go
@@ -10,8 +10,9 @@ code:
- mesh-controller cmd/mesh-controller/source.go - mesh-controller cmd/mesh-controller/source.go
- mesh-controller internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql - mesh-controller internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql
- mesh-catalog modules/gitea/module.json - mesh-catalog modules/gitea/module.json
updated: 2026-10-01 updated: 2026-10-03
decisions: decisions:
- 02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
- 02-DECISIONS/0161-what-deserves-a-seat.md - 02-DECISIONS/0161-what-deserves-a-seat.md
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md - 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md - 02-DECISIONS/0126-a-module-declares-its-own-seats.md
@@ -126,7 +127,8 @@ convention, which later seats departed from.
| `mesh-npm-package-registry` | `npm-package-registry` | mesh | `npm-package-registry` | the forge | | `mesh-npm-package-registry` | `npm-package-registry` | mesh | `npm-package-registry` | the forge |
| `mesh-git` | `git` | mesh | `git` | the forge | | `mesh-git` | `git` | mesh | `git` | the forge |
| `mesh-build-machine` | `the-build-machine` | node | — | a builder | | `mesh-build-machine` | `the-build-machine` | node | — | a builder |
| `mesh-dns-port` | `the-dns-port` | node | — | the local resolver | | `mesh-resolver` | — | mesh | — | the mesh's one resolver, holding every node's internal domain ([ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)) |
| ~~`mesh-dns-port`~~ | `the-dns-port` | node | — | retired by [ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md): the local resolver became the mesh's one |
| `mesh-intrusion-prevention` | `the-intrusion-prevention` | node | — | an intrusion-prevention service | | `mesh-intrusion-prevention` | `the-intrusion-prevention` | node | — | an intrusion-prevention service |
| `mesh-packet-filter` | `the-packet-filter` | node | — | the packet filter | | `mesh-packet-filter` | `the-packet-filter` | node | — | the packet filter |
| `mesh-private-network` | `the-private-network` | node | — | the private network the mesh runs over | | `mesh-private-network` | `the-private-network` | node | — | the private network the mesh runs over |
@@ -14,7 +14,7 @@ decisions:
- 02-DECISIONS/0084-which-provider-serves-a-consumer.md - 02-DECISIONS/0084-which-provider-serves-a-consumer.md
- 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md - 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md
- 02-DECISIONS/0038-the-mesh-assigns-the-port.md - 02-DECISIONS/0038-the-mesh-assigns-the-port.md
- 02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md - 02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md
--- ---
# 27 — A module requires, the mesh resolves # 27 — A module requires, the mesh resolves
@@ -208,7 +208,7 @@ the placeholder allows: the definition says which values reach which requirement
does. *How it is checked:* the unit tests named in issue 173, and the plan comparison that closed it. does. *How it is checked:* the unit tests named in issue 173, and the plan comparison that closed it.
*A provider says once what it derives for each consumer (2026-10-02, *A provider says once what it derives for each consumer (2026-10-02,
[ADR 0188](../../02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md), [ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md),
[issue 124](../../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)):* [issue 124](../../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)):*
where a provider **names the resource** it gives each consumer — a bucket, a database, a vhost — the where a provider **names the resource** it gives each consumer — a bucket, a database, a vhost — the
name is derived per consumer, and a literal `serves` block could not carry it. A served value may name is derived per consumer, and a literal `serves` block could not carry it. A served value may
@@ -221,7 +221,7 @@ its binding's served facts and as `${bound:<provision>:<key>}` in any file it wr
as `derived` on that consumer's entry in its contributions file, so its provisioner is told the name as `derived` on that consumer's entry in its contributions file, so its provisioner is told the name
rather than recomputing it. A consumer that writes the derived value into its own definition instead rather than recomputing it. A consumer that writes the derived value into its own definition instead
of asking for it is refused, naming the placeholder to use. *How it is checked:* the unit tests in of asking for it is refused, naming the placeholder to use. *How it is checked:* the unit tests in
ADR 0188's "how this is checked", each run against the unchanged controller first. ADR 0202's "how this is checked", each run against the unchanged controller first.
## How a definition reads what was resolved ## How a definition reads what was resolved
@@ -11,8 +11,10 @@ code:
- mesh-host internal/apply/apply.go - mesh-host internal/apply/apply.go
- mesh-tools src/main.ts - mesh-tools src/main.ts
- mesh-catalog modules/mesh-catalog - mesh-catalog modules/mesh-catalog
updated: 2026-10-02 - mesh-tools node-tools/internal/runtime (a module's state, ADR 0201)
updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md - 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md - 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md - 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
@@ -67,6 +69,8 @@ the catalogue, and the mesh would have hundreds of copies of a decision it made
| `tools: status` | queue-group subscription on `mesh.mod.<module>.tool.status` | | `tools: status` | queue-group subscription on `mesh.mod.<module>.tool.status` |
| seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.accept.send` | | seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.accept.send` |
| `uses: telegram-sender` | publish on that seat's `accept` subjects, and nothing else | | `uses: telegram-sender` | publish on that seat's `accept` subjects, and nothing else |
| `state: servers` | a key-value bucket for the module, created by the controller; its instances write and read it |
| `reads: billing.orders` | read and watch billing's `orders` bucket, and nothing else of it |
**Wildcards, and they are the mesh's rather than a bus's.** *Added 2026-09-27, from **Wildcards, and they are the mesh's rather than a bus's.** *Added 2026-09-27, from
[issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md).* A [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md).* A
@@ -221,7 +225,7 @@ service" versus "one worker per machine".
| credential | sealed, per consumer | none | none | none | none | | credential | sealed, per consumer | none | none | none | none |
| reply | — | none | none, or an event later | a report | awaited | | reply | — | none | none, or an event later | a report | awaited |
| retention | — | age and size | work queue, explicit ack | **last per subject** | none | | retention | — | age and size | work queue, explicit ack | **last per subject** | none |
| declared | `provides`/`requires` | `emits`/`consumes` | seat `accepts` / `uses` | the mesh's own | `serves` | | declared | `provides`/`requires` | `emits`/`consumes` | seat `accepts` / `uses` | the mesh's own; a module's `state` / `reads` | `serves` |
**Job** is the one [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) had no room **Job** is the one [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) had no room
for. Its table has events at 1:many and provisions at 1:1; a module submitting work to a service for. Its table has events at 1:many and provisions at 1:1; a module submitting work to a service
@@ -236,6 +240,31 @@ last-per-subject retention, and a node that has seen sequence *n* refuses *n−1
That is the wire-level answer to That is the wire-level answer to
[issue 107](../../04-ISSUES/107-a-declaration-carries-no-order/00-report.md). [issue 107](../../04-ISSUES/107-a-declaration-carries-no-order/00-report.md).
**A module declares state too.** *Added 2026-10-04,
[ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md).*
State was the mesh's alone, and modules had the same need with nowhere to put it: an MCP server
registered for every machine, sent as an event, never reached a machine assigned afterwards — its
consumer did not exist yet when the event passed — and a licence binding sent as events replays a
week of rotations where only the latest matters. So a module names the state it **owns** with
`state`, and another module's it **reads** with `reads: <module>.<name>`. Each is a key-value bucket
the controller creates from the catalogue, mesh-wide, existing from registration so a reader can
watch before the owner runs anywhere ([design 25](25-the-bus-on-nats.md) §3). Every instance of the
owner writes; a reader reads and watches. A key may name a machine by the module's own convention;
the mesh keeps one bucket per name, not one per machine, because "every server, for every machine"
is then one list rather than a walk.
What a module sees is what it named. Its assignment's membership lists its buckets by those names,
with whether it may write, and the runtime answers `get`, `put`, `delete`, `keys` and `watch` for
them on the bundle's channel — refusing, with the reason, a name it was not issued or a write to a
bucket it only reads. A watch hands the current values first, then every change: a bundle that starts
late, or starts again, has the whole of the state before it has any of the news.
The owner says how many past values a key keeps and how long a value lives, as a seat says how long
its backlog survives (§3); the mesh caps a value's size and a bucket's. **A bucket outlives its
module's assignment** — what a module stored is data
([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md)) — and one whose
declaration is gone is reported, never removed by the mesh.
## 5. Seats ## 5. Seats
A module declares a seat with its protocol, and the mesh enforces one holder at its scope A module declares a seat with its protocol, and the mesh enforces one holder at its scope
@@ -454,6 +483,14 @@ sealing key leaks, that stream is an archive rather than a moment. So:
existing discipline — *fetched from it, not carried* — applied to the one payload where carrying existing discipline — *fetched from it, not carried* — applied to the one payload where carrying
it is worst. it is worst.
**A key-value bucket is a stream, so the same holds for it.** *Added 2026-10-04,
[ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md).*
No secret is put in a module's state, sealed or not: state is exactly what a machine joining a year
later reads in full. A value that needs a secret names it, and the secret travels on request/reply.
Sealed values are plain text to anything inspecting them, so this is checked only partly — the
runtime refuses a value carrying a field whose name says it is a credential, which catches the
ordinary mistake and not a determined one.
**The bootstrap, which is circular and has a precedent.** The vault makes every secret **The bootstrap, which is circular and has a precedent.** The vault makes every secret
([ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md)), including the bus's own ([ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md)), including the bus's own
passwords. The vault is a module, and a module needs a bus account, whose password the vault passwords. The vault is a module, and a module needs a bus account, whose password the vault
@@ -523,3 +560,9 @@ billing existing under that name.
on, and exactly those two are rebuilt. on, and exactly those two are rebuilt.
- **A stale declaration is refused.** A bed: replay sequence *n−1* after *n*, and the node refuses - **A stale declaration is refused.** A bed: replay sequence *n−1* after *n*, and the node refuses
it rather than applying it. it rather than applying it.
- **A module reaches only the state it declared.** The composer's test: an owner's runtime may write
its buckets, a reader's may only read, and nothing else is granted; the runtime's test over a real
bus: a name not issued and a reader's write are refused with the reason.
- **State is current at once.** The runtime's test over a real bus: a watch hands the current values
without deletions, then an end-of-current marker, then changes. Live: a machine assigned after a put
reads it at start.
+26 -2
View File
@@ -1,9 +1,11 @@
--- ---
layer: to-be layer: to-be
status: implemented status: designed
code: [mesh-catalog, mesh-tools, mesh-controller] code: [mesh-catalog, mesh-tools, mesh-controller]
updated: 2026-10-02 updated: 2026-10-03
decisions: decisions:
- 02-DECISIONS/0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md
- 02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.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/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md
- 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md - 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
@@ -93,6 +95,28 @@ prerequisites are listed in that record. When the seat serves them, the console
modules' own, and the person stops opening a shell for the mesh's own questions. Until then the console modules' own, and the person stops opening a shell for the mesh's own questions. Until then the console
says so in its handshake. says so in its handshake.
## 3a. Found by address, not announced whole (2026-10-03)
*By [ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md);
this section governs where it and §2–§3 disagree.* The console announces five tools —
`mesh_overview`, `mesh_machine`, `mesh_search`, `mesh_describe`, `mesh_call` — and every tool the mesh
answers is reached through them by its address: `<seat>.<verb>` for a seat held once for the mesh,
`<node>/<seat>.<verb>` for one held per machine, `<node>/<module>.<tool>` for an assignment, and
`<module>.<tool>` as well for a module whose instances are interchangeable. A module that is not
interchangeable is called with its machine or refused with the machines it runs on. Each discovery
verb asks the mesh when it is called, so nothing is kept for a session's length; the flat catalogue
stays reachable through the `mesh` client and a setting, unannounced.
**What exists is what announced itself** ([ADR 0197](../../02-DECISIONS/0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)):
every runtime answers the NATS services protocol's `$SRV.INFO` with what it serves, and the console
gathers one request's answers; the controller's records, read as JSON, say which assignments with
tools should have answered.
*Found 2026-10-03, measuring for that record:* §3's statement that a stateful module on two machines is
listed once per machine does not hold on the live console — postgres and mssql are listed once, `node`
optional, answered by whichever instance replies. The address replaces that statement rather than
repairing it.
## 4. Where it runs ## 4. Where it runs
On whichever machines an operator sits at, by assignment. It is not on the control node by default and On whichever machines an operator sits at, by assignment. It is not on the control node by default and
@@ -2,8 +2,9 @@
layer: to-be layer: to-be
status: designed status: designed
code: [] code: []
updated: 2026-10-02 updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md - 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/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/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
@@ -54,8 +55,10 @@ instruction file and the manager's tools:
| the console's entry in the agent's user-scope state | the managed settings' tool-server key, from the console's provision (§4) | | 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) **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 the module owns the directory `~/.claude` — that it exists, that the operator owns it, its mode,
module's own code writes for a subscription licence (§5). The person's memory, history, projects, local `0700` — and declares it, so the mesh refuses a second module owning it. Of what is inside, it owns only
the agent's credentials file, which its own code writes for a subscription licence (§5); every other path
is *found*. 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 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 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. lists them, and until they go the agent reads stale instructions beside the mesh's.
@@ -63,9 +66,12 @@ lists them, and until they go the agent reads stale instructions beside the mesh
## 2. What the module declares and what its code writes ## 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 **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 in that directory carrying the node's name and the console's endpoint, and a settings file carrying the
settings; the bus, the console's provision, and that it uses the `anthropic-licence-manager` seat. role and the extra tool servers, merged from the module's settings layers — the bundle is told the two
Nothing under the home, nothing under `/etc`. files' paths, because a bundle's words are paths and constants only (ADR 0192); the bus, the console's provision, and that it uses the `anthropic-licence-manager` seat.
Two directories, declared so the ownership check sees them: the agent's managed directory under
`/etc`, root's, and `~/.claude` under the operator's home, the operator's. No *file* resource under
either: what is in them is written by the module's code (§2 below) or is the person's.
**Written by the module's code**, from the facts file and the manager's hand-over, whenever either **Written by the module's code**, from the facts file and the manager's hand-over, whenever either
changes: changes:
@@ -111,17 +117,20 @@ the playbooks in the record.
## 4. The console ## 4. The console
The module tells the agent where the console is, and the port is the console's to say. **The console > **Revised 2026-10-03, building it.** The vendor's managed-settings key for tool servers refuses any
provides a node-scoped provision** — its MCP endpoint on loopback — serving the port the machine gave > URL that is not `https://`, including one on loopback, so it cannot carry the console. The module
it, and the module requires it. A requirement names what the consumer is coupled to > owns the vendor's **exclusive** managed tool-server file instead (operator's choice): the console as
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)); co-location > `mesh`, over HTTP on loopback, and every server in the module's `mcp_servers` setting — and no other.
resolves it; a machine without the console refuses the module by name. [To-be 34](34-the-console.md) is > A server added by hand, a project's own file and a plugin's servers stop loading; claude.ai's
amended in the same change; issue 192 (open) found the gap. > connectors are kept by a managed setting. A person's own servers move into the setting, for the mesh
> or for one node. Stdio straight to the bus, through the runtime's own client, was weighed and left
> for later: it needs a verb the delivered runtime does not have, and a session holding its own bus
> connection breaks on a credential rotation.
**Other tool servers** a person wants on every machine, or on one, are a declared setting of this module **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`, — mesh layer or node layer — rendered into the same managed file. The person sets them with the
validates a server and sets the setting through the controller's settings verb, so the list stays controller's `settings` verb on this module, so the list stays declared state; the list is the operator's
declared state. The agent's own HTTP-only constraint for managed servers applies; a person's local choice, set where every setting is set. 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. 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 **The entry's name is `mesh`.** The hand-made entry both workstations carry today is named after this
@@ -131,36 +140,42 @@ it is the person's to remove, and until then the agent sees the mesh's tools twi
## 5. The licence: the consumer side ## 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) [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: decides it and [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md) says how it moves; 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; - **makes a keypair** in its state the first time it runs, and sends the public half with every request
- **serves `apply`**: the manager's hand-over, a token sealed to the module's key, with the licence's that is answered sealed;
name and kind. A rotation of the same licence is applied only if newer within one lineage; a switch is - **reports what the node holds**, as its own `holdings` state, one key for this node: the account's
applied regardless, because across licences the expiries are unrelated. The answer says applied or identity read from the agent's state file, the kind, the refresh token's fingerprint and whether one is
refused and why, and never echoes a token; present, the access token's fingerprint and expiry, the licence and generation it last applied, when the
- **pulls** at start and when its token nears expiry, by the seat's `current` verb, and keeps the last credentials file last changed. Written at start — a node already logged in reports at once — and on every
token when the manager does not answer, saying so; change of the file. Never a token: the runtime refuses one anyway;
- **writes** for a subscription licence the credentials file as the operator, access-token-only; for the - **hands over a grant only when asked**: `claude_code_grant` answers the manager, which gives its public
API-key licence sets the key-helper in the managed settings to a small program that prints the key key, with the full grant in the credentials file sealed to that key — the one time a refresh token
from the module's state, so no file under the home is touched; leaves the node, for the manager to adopt by refreshing it;
- **offers a login to the manager**: when the credentials file changes by a person's login, it reads the - **watches the manager's `bindings` state** for this node, and when the generation is newer than the one
account's identity from the agent's state file and offers the grant to the seat, sealed to the manager's it applied, asks the seat's `current` verb for the token, sealed to its own key. A rotation of the same
key, for adoption; the manager decides; licence is applied only if newer within one lineage; a switch is applied regardless;
- **serves `licence_status`**: which licence and kind this node holds, when the token expires, whether - **writes** for a subscription licence the credentials file as the operator, **access-token-only** — so
the agent here never refreshes, and a refresh token appearing later is a person's login, reported like
any change; 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;
- **serves `claude_code_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. 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 Switching is the seat's `switch` verb, asked through the console; this module only applies what the
handed. state says it should hold. *2026-10-04:* this replaces the manager's visits of 2026-10-03 (ADR 0183's dated
note): the node reports, the manager asks for a secret only when a report shows one it does not hold, and
a token is fetched by request when the state says it changed.
## 6. Scope, settings and the order of assignment ## 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)). **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:** All four nodes carry one since 2026-10-03. **Per node:** the role. **Per mesh or per node:**
extra tool servers. **Prerequisite:** the manager holds its seat and has adopted the licences. 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 **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 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 new session read to confirm it sees the mesh's instruction file, the console's five tools under `mesh`, and
its licence; then the rest. its licence; then the rest.
## 7. The package ## 7. The package
@@ -178,13 +193,13 @@ installer is rejected: it puts a self-updating binary under the person's home, i
| Check | Defends | | 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 | | the module's definition names no node, path or login, declares no file under a home or `/etc` (only the two directories), 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 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 | | 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 | | 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 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 | | 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 | | a new session on the assigned workstation lists the console's five tools under `mesh` ([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)) and answers "which node am I" from the instruction file | the exit of the build |
## What this does not settle ## What this does not settle
+13 -8
View File
@@ -2,7 +2,7 @@
layer: to-be layer: to-be
status: in-progress status: in-progress
code: [mesh-host, mesh-controller, mesh-tools, mesh-catalog] code: [mesh-host, mesh-controller, mesh-tools, mesh-catalog]
updated: 2026-10-02 updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.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/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
@@ -33,12 +33,16 @@ This is the design [to-be 29](29-a-node-has-operator-accounts.md) §2 called *a
Worked on the first one, a shell. The `zsh` module declares: Worked on the first one, a shell. The `zsh` module declares:
- a **package**, `zsh`; - a **package**, `zsh`;
- **files under the home**, owned by the account: the shell's rc file with the module's default - **files under the home**, owned by the account: the mesh's block at the start of the shell's rc
configuration, carrying a kept region for the operator's own lines, and `${setting:…}` file with the module's default configuration and the slots other modules' code lands in, the
placeholders for the few values a node varies; the account and its home are machine facts the operator's own lines kept after it, and `${setting:…}` placeholders for the few values a node
controller resolves ([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), varies; the account and its home are machine facts the controller resolves
to-be 29 §2); ([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md),
- a **seat declaration**, `login-shell`, node-scoped, with its one verb; and a **claim** on it; to-be 29 §2). Its environment is a contribution to the environment module, not lines of its own
([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
[ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
[to-be 41](41-the-shell-and-the-accounts-environment.md));
- a **claim** on the mesh's node-scoped seat `node-login-shell`, with its one verb;
- a **`user` shape** naming the shell, applied only where the module holds the seat; - 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 - a **tools bundle**, the artifact kind for interpreted code, with `execute` and the module's own
`show-config`. `show-config`.
@@ -80,7 +84,8 @@ root escalates itself.
## 4. The seats of the environment ## 4. The seats of the environment
Decided now: **`login-shell`** (module-declared; zsh, fish, bash; verb `execute`) and Decided now: **`node-login-shell`** (the mesh's own, ADR 0204; zsh, fish, bash; verb `execute`),
**`node-environment`** (the mesh's own, ADR 0203; the environment module; no verbs) and
**`node-service-manager`** (the mesh's own; systemd; verbs over units in both scopes). The rest **`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), 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 one record each when its first holder is written: display server, display session, terminal
@@ -2,7 +2,7 @@
layer: to-be layer: to-be
status: in-progress status: in-progress
code: [mesh-tools, mesh-controller, mesh-host, mesh-catalog] code: [mesh-tools, mesh-controller, mesh-host, mesh-catalog]
updated: 2026-10-02 updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md - 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/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
@@ -11,6 +11,11 @@ decisions:
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.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/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
- 02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md - 02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md
- 02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md
- 02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md
- 02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md
- 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
--- ---
# 38. Building the operator's machine # 38. Building the operator's machine
@@ -97,6 +102,19 @@ what `tools` answers, and the others serve. The runtime reads `MESH_OPERATOR_ACC
five tools and two seat verbs answer on their subjects; `tools` names the failed bundle; a 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. membership republished mid-run re-subscribes without a restart.
*Built and proven 2026-10-02* (mesh-tools, branch `feat/the-operators-machine`, commit `6390d1d`).
**WP1b — the launcher beside the loader** ([ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)).
*mesh-tools, mesh-sdk. A day for the skeleton.* A bundle whose entry is not JavaScript is launched
as a child process with the runtime's environment and spoken to over MCP on stdio: `tools/list`
once, `tools/call` per call; a tool named `<seat>.<verb>` is the seat's implementation. A child
that exits is named as a failed bundle and restarted on the next call. The TypeScript import stays
as the shortcut. Beside it, one skeleton SDK per language of the first set — the stdio loop and the
tool-definition type, nothing else — each proven by one bundle in that language answering one tool
in the runtime's test. **Proof.** The runtime's test: a bundle in a second language, launched, its
tool answering on its subject over a real bus; the TypeScript fixture served through the protocol
with the shortcut off answers the same.
## WP2 — The controller composes one runtime per node ## WP2 — The controller composes one runtime per node
*mesh-controller. Two to three days; the largest package.* *mesh-controller. Two to three days; the largest package.*
@@ -111,12 +129,23 @@ membership republished mid-run re-subscribes without a restart.
declaration gains an `archive` placed under a directory the controller derives, so the host 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. 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 3. **The runtime's process.** One `process` per node running the runtime from its own bundle
(WP3), `MESH_TOOL_MODULES` composed from the unpacked entrypoints, `MESH_OPERATOR_ACCOUNT` and (WP3), `MESH_TOOL_MODULES` composed from the unpacked entrypoints — each as
`<module>=<path>`, and the runtime decides from the file whether it is loaded or launched
(WP1b) — `MESH_OPERATOR_ACCOUNT` and
`MESH_OPERATOR_HOME` from the account fact, `restart-on` naming every bundle so a push that `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. 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 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 refused at registration once the runtime module is registered, naming this record. It is the
mechanism that keeps the old pattern from returning by habit. mechanism that keeps the old pattern from returning by habit. ADR 0188 widens it, after WP4:
a module whose own code is an image artifact is refused, whatever image it is built on.
*Amended 2026-10-02, at WP3.* The gate refuses the pattern **spreading**, not standing: a module
new to the catalogue in that shape, or one that had already moved to a bundle and returns to it,
is refused; a module the catalogue already holds in that shape — judged from the manifest it
holds and what that module's newest build stood on — is rebuilt without complaint. The day the
runtime arrives some thirty such modules stand, each moves in its own change from WP4 on, and a
gate refusing every rebuild in the meantime would stop the catalogue's pipeline to make a point
this record already makes.
**Proof.** Composition tests: a node with three assigned modules, one holding a seat, yields one **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 process, three archives, one node principal whose grants are the union, and the same three
@@ -138,6 +167,22 @@ runtime's `serve` keeps answering MCP on loopback; the person's end of it keeps
wrote, `tools/list` on loopback answers as before, and the controller's verbs answer through it. 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`. This is the first live step, and it is reversible by re-assigning `mesh-console`.
*Decided 2026-10-02:* `mesh-tools` keeps its name as the module the TypeScript images come from, and
`node-tools` is a second module in the same repository ([ADR 0069](../../02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md))
rather than a rename — the thirty-five manifests that build `on` `mesh-tools` stay true. Three things
WP3 found that the plan did not say: a TypeScript bundle must carry its dependencies and a
`package.json` naming its files as ES modules, which the toolchain now copies in from its own image;
the runtime's credential must be owned by the account the runtime runs as, which the controller
composes; and `MESH_TOOL_MODULES` is empty on a node where the runtime is the only bundle, which the
runtime accepts. *Built 2026-10-02* (mesh-tools `c46f950`, mesh-controller `ca7e81e` `773b561`
`729a5f9`). *Proven live 2026-10-02/03, on all four machines*: the console's container is gone,
`node-tools` runs as a unit the host wrote, as the operator's account, `tools/list` on each loopback
answers with the same 219 tools as before, and the controller's verbs answer through it; `mesh-console`
retired from the catalogue. Three things the step found are issues
[203](../../04-ISSUES/203-a-fresh-assignment-is-pushed-before-its-credential-exists/00-report.md),
[204](../../04-ISSUES/204-a-controller-handover-re-sent-every-node-a-stale-declaration/00-report.md) and
[205](../../04-ISSUES/205-a-package-resource-fails-against-a-stale-package-database/00-report.md).
## WP4 — The first holder moves: the packet filter ## WP4 — The first holder moves: the packet filter
*mesh-catalog. Half a day. The live proof of ADR 0175.* *mesh-catalog. Half a day. The live proof of ADR 0175.*
@@ -150,8 +195,149 @@ tool where they need root, which they have, since the runtime runs as the node's
four machines; `docker ps` shows no `mesh-nftables`; `status` is well. Then the fail2ban holder 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. proposed in an open change follows the same way when it lands.
*Found 2026-10-03, before the step ran:* a bundle imported in-process brings its own copy of the SDK
(WP3's *carry its dependencies*), and the SDK's tool registry is the copy's own — the first module
loaded beside the runtime would have registered its tools where the runtime never looks, and served
nothing, silently. Issue
[209](../../04-ISSUES/209-a-bundles-own-sdk-copy-registers-into-a-registry-the-runtime-never-reads/00-report.md):
the runtime now resolves every bundle's import of the SDK to its own copy, one registry and one
broker per node. Two things the package did not say, settled in the module: the filter's commands
run through `sudo` without a prompt where the runtime is not root, since the operator's account may
escalate as the operator would; and a bundle has no environment of its own, so the tool reads the
filter from the path the manifest's `filtering` names rather than from a variable the container used
to carry, a test holding the two together. Three things a review of the change found: the module's
own bus credential and state directory went with the container, since nothing reads them once the
runtime speaks with the node's (the shell module of WP5 declares neither); the `iptables` package the
image used to carry is now declared on the host; and that the operator's account may escalate without
a prompt is a fact about the machine the mesh neither declares nor checks — true on all four today,
and when it is not, the tool names it by how it failed, which is the only check there is until a
record says where the fact belongs.
*Built 2026-10-03* (mesh-tools `7152148` for issue 209, mesh-catalog `db5e7c8`). *Proven live
2026-10-03, on all four machines*: `node-packet-filter.rules`, `reload` and `remove` answer from the
node's runtime on each — `rules` and the module's own tool list the mesh's table, `reload` loads the
file and answers with the table, `remove` refuses the mesh's own table by name — `docker ps` shows no
`mesh-nftables` on any, the container's credential is gone with it, and `status` is well. One thing
the step found is issue
[210](../../04-ISSUES/210-the-host-re-creates-the-nodes-runtime-on-every-reconcile/00-report.md):
the host re-creates the runtime's process on every reconcile (resolved the same day, mesh-host #80).
*fail2ban followed 2026-10-03* (mesh-catalog `aa5bf7d`), the same shape: container, base images,
credential and state directory gone, the client through `sudo` since the daemon's socket is root's;
proven on all four machines — `status`, `banned` and the module's own `fail2ban_settings` answer from
the runtime, no `mesh-fail2ban` container, the runtime serving both bundles. Two holders moved; of the
thirty-three tool containers the catalogue held, thirty-one remain, and all but these two carried their
module's configuration and secrets in the container's environment, which a bundle does not have — the
question research [020](../../01-RESEARCH/020-what-a-bundled-tool-is-given/00-overview.md) opened
and [ADR 0192](../../02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md)
settled the same day: a tools bundle declares `env` on its artifact, the composer resolves it as a
container's, the runtime hands each bundle its own. That is WP4b below.
## WP4b — Every tool container moves
*mesh-controller, mesh-tools, mesh-catalog. One day. The rest of ADR 0175, under ADR 0192.*
**What changes**, in order: the manifest's tools artifact gains `env` and the catalogue check
refuses a secret's content in it; the composer resolves a bundle's `env` per machine and carries it
beside the bundle's archive, `restart-on` included; the runtime hands each bundle its own
environment — the contributor's argument for an imported bundle, the child's environment for a
launched one — and a test holds two bundles apart. Then the thirty-one remaining tool containers
move in one change: each container's `env` becomes its tools artifact's, mount targets folded into
the host paths they came from, the container, its base images, its Dockerfile and its own bus
credential gone. Last, the registration gate refuses the container shape for every module.
**Proof.** The controller's and the runtime's tests named in ADR 0192; live, every module's tools
answer from the runtime on the machines that run it, `docker ps` shows no tool container on any of
the four, and `status` is well.
*Found 2026-10-03, building it:* of the thirty-one, nine run only tools, three a main of their own,
and twenty import the module's own event handlers and provisioners beside their tools (ADR 0192's
dated note). WP4b moves the tools-only nine; WP4c holds the rest. Two of the nine stay with WP4c as
well — one carries a run-once provisioning step in a second container, one reads an env-file and two
sockets — so seven move here. *Built 2026-10-03:* mesh-sdk #12 (`collectToolsEach`), mesh-tools #33
(each bundle its own environment), mesh-controller #236 and #237 (the words composed, made the
account's to read, and named files restarting the runtime), and the seven modules in one change.
Building it found issue [211](../../04-ISSUES/211-a-bundle-is-built-before-the-toolchain-it-is-compiled-in/00-report.md).
*Proven live 2026-10-03* (mesh-catalog #242, #243): on the one machine that runs them, baserow,
letta, searxng and unifi answer from the runtime with no tool container — each reading its
configuration file as the operator's account — and the runtime serves seventeen tools for six
modules there. confluence, gitlab and jira are assigned nowhere and retire with the predecessor.
Two traps met on the way: a tools bundle whose module declares no `tools` list must say `loads`, or
the composer delivers it nowhere while the build reports success; and a module whose builds are
pinned to an old commit is left out of a merge's plan and must be built from `main` by hand.
## WP4d — Every served bundle is launched; the runtime in Go
*mesh-sdk, mesh-controller, mesh-tools. [ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md).*
**In order.** The SDK's stdio loop serves what a bundle registered under the module it is told it
serves as. The builder writes, beside every TypeScript entrypoint, an executable launcher that
imports it and serves what it registered; the composer names the launcher where it named the
entrypoint. The runtime launches every served entrypoint and imports none; the resolve hook and the
per-registration environment go. Proven live on all four machines. Then the runtime is rewritten in
Go against the same contract — the bus, the memberships and seats, the launcher, the console's MCP
over HTTP — and replaces the TypeScript one, proven the same way.
**Proof.** The tests ADR 0193 names; live, every moved module's tools and both node seats answer from
launched bundles on every machine, and then do again from the Go runtime.
*Built and proven live 2026-10-03.* mesh-sdk #13/#14 (0.1.4, 0.1.5: served as the named module; an
emit travels through the runtime), mesh-controller #239/#240 (a launcher beside every TypeScript
entrypoint; a runtime compiled to a binary runs itself), mesh-host #81 (`./name` is the process's own
binary), mesh-tools #35/#36/#37/#38 (the module named; launch-only; the toolchain requiring 0.1.5; the
runtime in Go). On all four machines node-tools is now the Go binary, launching every served bundle:
both node seats answered from it on every machine and the four moved modules on theirs. Found on the
way: issue [212](../../04-ISSUES/212-a-toolchain-rebuild-keeps-the-sdk-it-cached/00-report.md) (the
toolchain image kept a cached SDK, and the seats' verbs went unanswered on three machines for an hour),
and the controller's plan losing track of its own rebuild when it restarts mid-plan.
## WP4c — The module's own long-running code moves
*Not yet broken down.* Twenty-three containers carry code that is not a tool: event handlers,
provisioners, a step, a main. [ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
§3 already says such code is a `process` bundle the host runs. What no record says yet is how that
process is given what its container was: the module's own bus credential and the subscriptions it
consumes with, the words its code reads at import, the packages the image installed (a database's
client), and the service it reaches by a container network name. That begins with a decision record,
after which the twenty-three move and the registration gate refuses the container shape for all.
*Decided 2026-10-03, [ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md):*
the node's runtime launches that code as it launches tools, and is its bus — `mesh/subscribe` and
`mesh/ask` beside `mesh/publish` on the stdio channel, the module's own durable consumer bound by the
runtime and acknowledged only after the child answered. **In order:** the runtime's subscription and
its grants; the SDK's `on` and provisioner bound to the channel; then the modules in three waves — the
provisioners and handlers whose backends are reached on loopback with a system package (postgres,
redis, mosquitto, influxdb, keycloak, umami, cloudflare-dns, grafana, icecast, home-assistant, nodered,
nextcloud, minio), the two whose clients exist on no system (mongodb, mssql: a driver in the bundle),
and last the mesh's own (mesh-catalog, mesh-vault, records, gitea, mailu, audit-logger, lab, and the
three mains).
*Built 2026-10-04.* Thirty-four modules no longer run their own code in a container: the first wave
(mesh-catalog#245), the mesh's own and the media modules (mesh-catalog#248, mesh-media-catalog#13),
with a step run where and as it is declared (mesh-host#85) and a process's words filled like a
container's (mesh-controller#250). **Proven live** on every machine that runs them: each moved
module's tools answer from the node's runtime, the steps run as their oneshot units, and the forge's
merge events reach the build pipeline from the runtime — the merge after the move started its own
plan. **Two corrections the machines taught:** a tool that called a broker's command-line client now
runs it inside the broker's own container, because a host package may be uninstallable on a machine
whose package index is stale (mesh-catalog#249); and a module reading its application's own key reads
it through the application's container, because that directory belongs to the account the application
runs as there, which is not the runtime's (mesh-media-catalog#14). *Completed 2026-10-04:* the last three — mesh-catalog, mongodb and mssql, whose code imports npm
packages of its own — moved once the builder installs a bundle's own dependencies before compiling,
keeping the toolchain's SDK authoritative (mesh-controller#255, mesh-catalog#250). Their database
clients are now drivers inlined into the bundle, not command-line clients fetched by a container; one
more correction the machines taught: a driver reaching its server on loopback must give TLS a host
name, since the runtime's Node refuses an address (mesh-catalog#252). **No module's own code runs in
a container any more;** every module with tools answers from its node's runtime, proven by calling a
tool of each.
## WP5 — The shell, on a server first ## WP5 — The shell, on a server first
*Replaced on 2026-10-04 by [to-be 41](41-the-shell-and-the-accounts-environment.md).* A review before
assigning found that the shell module would duplicate every machine's existing startup file, drop
lines from it, leave the prompt uninstalled, and could not be unassigned
([issue 228](../../04-ISSUES/228-a-login-the-mesh-set-is-never-given-back/00-report.md)). The shell,
its environment, the modules that plug into it, and the host's fix are built and proven there. What
follows is the original plan, kept for the record.
*mesh-catalog #224, already written. Half a day to assign and prove.* *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"` **Order.** Assign `zsh` to one server; push; `login-shell.execute@<server> command="uptime"`
@@ -2,8 +2,9 @@
layer: to-be layer: to-be
status: designed status: designed
code: [] code: []
updated: 2026-10-02 updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md - 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/0024-model-access-is-a-provision.md
- 02-DECISIONS/0050-model-access-is-vendor-agnostic.md - 02-DECISIONS/0050-model-access-is-vendor-agnostic.md
@@ -74,22 +75,22 @@ Carried from the predecessor, where each rule was earned by an incident:
## 4. Handing a token to a node ## 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 *Amended 2026-10-04 by [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)*, replacing the manager's visits: **what each consumer
runs. From then on: should hold is the manager's state, and the token is fetched when it changes.**
- **On rotation**, the manager calls `claude-code.apply@<node>` on every node bound to the rotated - **The manager keeps a `bindings` state**, one key per consumer: the licence, its kind, and a
licence, with the new token sealed to that node's module key. The module answers *applied*, or **generation** that increases with every rotation and every switch. Nothing in it is secret.
*refused* and why, and the manager records it. - **The agent module on each node watches its own key.** When the generation is newer than the one it
- **On a switch**, the same call with the other licence's token, and the binding is the authority: the applied, it asks the seat's `current` verb, sending its public key, and is answered with the token
module applies a bind without comparing expiries, because across two licences the numbers are sealed to it — request/reply, never an event. A node that was away reads its key when it is back and
unrelated. asks once; a manager that is down leaves every node on its last token, which lives hours.
- **On a pull** — the module starting, or finding its token near expiry — the module calls the seat's - **On a switch** the agent applies the new licence's token without comparing expiries, because across
`current` verb for its binding and is answered sealed the same way. two licences the numbers are unrelated; within one licence it applies only a newer grant.
- **Never as an event.** What the manager emits names the licence and the outcome and carries no token. - **No event announces a rotation or a switch.** What they announced is the state itself, and a node
needs the latest, not the history. What the manager still emits names an 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 A consumer that never asks is visible: its own report (§6) names the licence and generation it holds,
rather than falling silent. A node whose module refuses — a wrong identity, a stale grant within one and a node behind its binding is drift the manager reports.
lineage — is recorded as drift and reported.
## 5. Who gets which licence ## 5. Who gets which licence
@@ -116,27 +117,46 @@ already keeps.
## 6. Adopting a grant ## 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 *Amended 2026-10-04 by [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)*: a licence is an account, learned from what the nodes
argument: report, and adopted by refreshing it.
- **From a node's login.** A person logs in on a node, as they always have. The agent module there reads - **Every node reports what it holds**, as the agent module's `holdings` state, one key per node: the
the account's identity from the agent's own state file, and offers the full grant to the seat sealed account's identity read from the agent's own state file, the kind, the refresh token's fingerprint and
to the manager's key. The manager adopts it into the licence the node is bound to **only if the whether one is present, the access token's fingerprint and expiry, the licence and generation it was
identity matches** that licence's recorded account; a licence not yet identified is identified by its last handed, when the credentials file last changed. Written when the module starts — a node already
first adoption; a mismatch is refused and notified, because the predecessor once filed one account's logged in reports at once — and on every change. Never a token.
grant into another's row this way. - **The manager reads every report at start and watches them.** A report with a refresh token whose
fingerprint the manager does not hold is a candidate: a new licence for an account it has none for, a
login made since for one it has. A manager launched for the first time holds no licence and takes every
report as a candidate.
- **The secret is asked for, never published.** For a candidate the manager calls that node's agent
module, giving its own public key, and is answered with the grant sealed to it.
- **Adopting is refreshing.** The manager exchanges the candidate's refresh token under its lease for
that account; success makes the returned grant the licence's and the manager its only rotation source;
failure records the candidate dead and adopts nothing. Candidates for one account are tried newest login
first, and the first that refreshes ends the search — the others are never exchanged.
- **The latest login wins.** A bound node is handed an access token only and its file holds no refresh
token, so a refresh token appearing there later is a person's login; its report makes it a candidate,
and if it refreshes it replaces the licence's grant.
- **A first binding follows the login**: a node with no binding whose report names the adopted account
is bound to it. Every later change is `bind`, `switch` or `release`.
- **The identity guard** files a grant under the identity the node read; where the vendor's refresh
answer names the account too, a mismatch is refused and notified. Which source decided is audited.
- **An API key** is delivered to the manager by the operator through the seat's `adopt` verb from a file - **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. on the manager's node, never as an argument.
## 7. What it emits and serves ## 7. What it emits and serves
**Events**, no secret in any: `licence.rotated`, `licence.switched`, `licence.adopted`, **Events**, no secret in any: `licence.adopted`, `licence.failing`, `licence.refused`, `usage.read` —
`licence.failing`, `licence.refused`, `usage.read` — the audit logger records them all. the audit logger records them all. *2026-10-04 (ADR 0206):* `licence.rotated` and `licence.switched` are
gone; a rotation or a switch is a new generation in the `bindings` state.
**State**: `bindings`, which it keeps; the agent module's `holdings`, which it reads.
**The seat's verbs**, the contract every future holder must serve: `licences` (each with kind, **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 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 or all), `usage` (current and history), `adopt`, and `current` (a consumer's token, sealed to the key the
consumer's token, sealed, asked by the consumer's module). consumer sends — ADR 0206). The manager asks a node for a candidate grant by the agent module's own tool.
## 8. Settings ## 8. Settings
@@ -0,0 +1,216 @@
---
layer: to-be
status: designed
code: []
updated: 2026-10-04
decisions:
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 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/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
- 02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md
- 02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md
- 02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md
- 02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md
---
# 40. Building the operator's agent and its licence manager
**The work of [design 36](36-the-operators-agent-on-a-machine.md) and [design 39](39-the-anthropic-licence-manager.md),
broken into packages that each end at something a person can see run, in the order their
dependencies allow.** The two designs are the authority on *what* is built; this document holds the
packages, their order, their sizes and their proofs, and is wrong the moment it disagrees with them.
It is the shape [design 38](38-building-the-operators-machine.md) gives the operator's machine.
*Revised 2026-10-03, after design 38's WP1–WP4b ran:* the node's tool runtime is live on all four
machines, tools are bundles it serves and each is given only the words its artifact declares, every
bundle is a child the runtime launches over stdio and is the bus for
([ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md),
[ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)),
and the console offers five tools over addresses
([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)). What changed
in this plan: the wait on design 38's WP3 is over; the manager starts every exchange, by the operator's
direction (ADR 0183's dated note); and the manager's daemon is a long-running bundle the runtime launches,
which ADR 0198 decided the same day — nothing in this plan waits on another record.
## How this is built, and where it is run
**On the live mesh, by the operator's decision** ([ADR 0149](../../02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md)).
Every package is written with unit tests, committed on one branch per repository
([playbook 07](../../00-META/process/07-feature-branches.md)), and proven on the machines: one
workstation first for the agent, the control node first for the manager, then the rest. A broken agent
module leaves a workstation's agent without the mesh's instructions or with a stale token until the next
push; the person's own files under the home are out of the failure's reach, by
[ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md).
Each package names what proves it. A package that cannot name its proof is divided until it can.
## What exists already, measured
Measured 2026-10-03 on the four machines and in the repositories.
| Piece | Today | Becomes |
|---|---|---|
| the node's tool runtime | live on all four, a host process; **runs as the operator account**; listens for the console on loopback at a port its own code fixes; launches or imports every assigned module's tools bundle and hands each its declared words | serves the agent module's tools; gains one provision for its endpoint (WP1) |
| the operator account | **stated on all four** — the runtime runs as it | read by the agent module from the runtime's own words |
| escalation | passwordless `sudo` for the operator account on all four — a fact about the machines, checked by nobody | how the agent module writes its managed directory under `/etc` |
| the agent itself | installed on all four, at four different versions, all above the one the managed tool-server key needs | declared as the module's package |
| a bundle's words | paths and constants written with `${dir:…}` and `${port:…}` only; a fact the mesh knows reaches a bundle as a file whose path is a word | the agent module's facts file and settings file |
| a bundle calling a tool | `mesh/ask` through the runtime that launched it (ADR 0198); no bundle holds a bus credential | how the manager visits every node |
| a module's own long-running code | a bundle the runtime launches and restarts (ADR 0198); the runtime's subscription and grants built, the modules moving in design 38 WP4c's waves | the manager's daemon (WP3, WP4) |
| the vendor's refresh, the sealed box, the grant file | `anthropic-manager` in the catalogue, built on the controller placement ADR 0183 moved away from; assigned to nothing | its client ported into the manager; the module retired (WP6) |
| the credentials write, the strip, the identity read | `anthropic-consumer` in the catalogue; tested; assigned to nothing | ported into the agent module with its tests; the module retired (WP6) |
| the predecessor's manager and consumer | the lease per licence, the expiry floor, the lineage comparison, the identity guard, three touchpoints, cooldowns | ported as logic with its tests |
## The order the work allows
```
WP0 the operator names the licences and each node's role (the live mesh) ── an hour
WP1 the runtime provides its endpoint (mesh-tools) ── small
WP2 the agent module (mesh-catalog) ──┐ WP2 needs WP1;
WP3 the manager's code, built and tested (mesh-catalog) ──┘ WP3 is independent
│
WP2 live: one workstation, configuration only — no licence yet ── the first live proof
│
WP4 the manager live on the control node ── its daemon a long-running bundle (ADR 0198)
WP5 the licence end to end on one workstation
WP6 the rest of the nodes, and the predecessor's remains
```
## WP0 — The operator names the licences and each node's role
*The live mesh. An hour, and it is the operator's.* The accounts are stated already. What remains: the
names of the two subscription licences and the API key; each node's role, as the agent module's setting
on the node layer once the module is registered.
**Proof.** The module's settings list a role for every node; the licences have names.
## WP1 — The runtime provides its endpoint
*mesh-tools. An hour.*
**What changes.** The `node-tools` manifest provides a node-scoped provision, `mcp-endpoint`, serving
the port its code listens on, the way the local model server serves its API
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)). Co-location
resolves it. The runtime's port stays what its code fixes; assigning it is
[issue 192](../../04-ISSUES/192-the-meshs-tools-reach-a-person-only-by-a-registration-made-by-hand/00-report.md)'s
second question and not this package's.
**Proof.** The plan for a workstation carrying a consumer of `mcp-endpoint` shows it bound to the
runtime's port; the controller's tests and the catalogue's checks pass.
## WP2 — The agent module
*mesh-catalog. A day and a half.*
**What is written**, as design 36 says:
1. **The manifest.** The agent's package. A state directory. A facts file in it, rendered by the mesh:
the node's name and the console's address from `mcp-endpoint`. A settings file in it, merged from
the module's settings layers: the node's role and the extra tool servers. A tools bundle whose
words name the two files, the state directory and nothing else. The two directories it owns declared — the agent's managed
directory and `~/.claude` — and **no file resource under either.**
2. **The renderer**, run whenever the runtime collects the module's tools: from the two files, the
managed settings file (the tool servers under the entry `mesh`, the attribution trailers, the
key-helper for an API-key binding) and the managed instruction file, written under the agent's
managed directory through the account's escalation, only when their content changed.
3. **The keypair**, made once in the state directory; X25519 and an authenticated cipher from the
language's own library, so the bundle carries no dependency.
4. **The tools and the state** (*2026-10-04, ADR 0206*): `claude_code_status` (what is rendered, what
licence is held, when its token expires, fingerprints only); `claude_code_render` (render now);
`claude_code_grant` (the full grant in the credentials file, sealed to the key the manager gives).
The `holdings` state, written at start and on every change of the credentials file; a watch of the
manager's `bindings` key for this node, which asks the seat's `current` on a newer generation and
applies the sealed answer — only if newer within one lineage unless it is a switch; the credentials
write as the operator, access-token-only, atomic; the key-helper program for an API key.
5. **The documentation**: the six predecessor files and the hand-made console entry a person removes.
**Proof, before anything runs live.** Unit tests: the renderer writes the mesh's keys and nothing else;
it writes nothing when nothing changed; the credentials write strips a refresh token and is atomic; the
lineage cases from the predecessor; a sealed hand-over opens only with the module's key; no tool's answer
contains a token. The catalogue's checks pass.
**Proof, live, on one workstation, configuration only.** Assign the module; set the node's role; push.
The agent's managed directory holds the two files; everything under the person's agent directory is
byte-identical to before; a new session lists the console's five tools under `mesh` and answers *which node am
I* from the managed instruction file. `claude_code_status` answers through the console. No licence is
touched: the module writes the credentials file only when it is handed a token.
## WP3 — The manager's code, built and tested
*mesh-catalog. Two to three days.*
**What is written**, as design 39 says: the manifest (the seat and its verbs, a database, a `secret`
for the key the grants are encrypted with, a tools bundle and a long-running bundle for the daemon, both launched by the runtime, settings
with defaults); the store's migrations; the refresh with its plan, lease, floor and cadence as pure
functions; the vendor client from `anthropic-manager`; adoption from a file and from a node's waiting
login with the identity guard; usage and its threshold; the seat's verbs. *2026-10-04 (ADR 0206):* in place
of the visit, the watch of every node's `holdings`, adoption of a candidate by refreshing it (newest login
first, once per account), the `bindings` state with a generation per consumer, and `current`.
**Proof, before anything runs live.** Unit tests: two refresh runs started together rotate one grant
once; a mismatching identity is refused; a worker bound to a dead licence is refused and never lent
another; nothing the daemon emits carries a token; a hand-over sealed for one node opens with no other
node's key.
## WP4 — The manager live on the control node
*The live mesh. Half a day.* The daemon is a long-running bundle the control node's runtime launches
(ADR 0198); it calls each node's agent module by `mesh/ask`. If the runtime's half of ADR 0198 is not
yet live on the control node when this package starts, this package waits for it: no tool container, no
credential copied by hand.
**Order.** Assign the manager on the control node; push. It reads every node's `holdings` and adopts each
account the nodes are logged in to, by refreshing the newest login's grant (ADR 0206); each node with no
binding is bound to the account it reported. Adopt the API key from a file there. A second subscription
account enters by a login on a workstation carrying the agent module.
**Proof.** Through the console: `anthropic-licence-manager.licences` lists three licences with identity
and expiry; within the cadence the audit shows a rotation and a later expiry; a forced `refresh` is
logged with the vendor's answer.
## WP5 — The licence end to end on one workstation
*The live mesh. Half a day. The proof of the whole.*
**Order.** Record the checksums under the person's agent directory. Bind the workstation to a
subscription licence. Remove the six predecessor files and the hand-made console entry. Start a session.
**Proof.** Everything under the person's agent directory is byte-identical but the credentials file,
which is owned by the operator, readable by nobody else, and names no refresh token. A session makes a
model request. `switch` to the second subscription licence changes the token on the workstation within a
minute, and neither the verb's answer nor either module's log holds a token. Switched to the API key, the
credentials file is left as it was and the agent authenticates through the key-helper. Switched back.
## WP6 — The rest of the nodes, and the predecessor's remains
*The live mesh and mesh-catalog. One day.* The module on every node, the predecessor's files removed on
the second workstation; a login under a licence's account collected and adopted, and one under the wrong
account refused and notified; `anthropic-manager` and `anthropic-consumer` retired from the catalogue;
designs 36 and 39 set to `implemented` with the as-is written
([playbook 02](../../00-META/process/02-graduation.md)).
**Proof.** `claude_code_status` answers on every node; the refusal's notification arrived; the catalogue
has no module built on the old placement.
## What is deliberately not here
- **The package repository seat** for a distribution that does not carry the agent's package (design 36
§7). The four machines have the package; a fifth would refuse the module in its package manager's
words.
- **Escalation as a checked fact.** The agent module's write under `/etc` relies on the operator
account's passwordless `sudo`, true on all four and checked by nothing. A machine without it refuses
the render in the tool's own words; making escalation a reported capability is design 38's to decide.
- **An automated switch on exhaustion.** The readings are kept from WP4; the policy is a later record.
- **Workers and the mesh's own sessions as consumers.** The manager knows them from WP3; the consumers do
not exist yet ([to-be 15](15-the-agent-session.md)).
- **Whether a refresh token is single-use.** WP4 may measure it; the design holds either way.
## How this list is kept true
Each package's proof is run when the package is finished and its line here gains the date and the
commit, as design 38 does. A package whose proof fails is not reworded; the failure is recorded under it
and the package stays open. When WP2's live proof runs, design 36 moves to `in-progress` with its owning
repository; when WP5's does, design 39 does too; and when WP6's does, both move to `implemented`, with
the as-is written.
@@ -0,0 +1,238 @@
---
layer: to-be
status: in-progress
code: [mesh-host, mesh-controller, mesh-catalog]
updated: 2026-10-04
decisions:
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.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/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
---
# 41. The shell and the account's environment
What it takes for the operator's shell to be modules, without a machine losing anything it does
today. This design replaces the shell half of
[to-be 38](38-building-the-operators-machine.md) WP5, and finishes the service-manager module of WP6
short of its user-scoped units. The decisions are [ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)
(the environment), [ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
(shell code and the seat) and [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md)
(vendored software). The evidence is [research 025](../../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md).
## What a node with a shell looks like
```
toolchain, agent, … prompt, plugins, version manager
│ environment │ shell (zsh, slot)
▼ ▼
node-environment ── holds ── node-env node-login-shell ── holds ── zsh
│ │
├─▶ ~/.config/mesh/environment.sh ◀─ sourced from zsh's block in ~/.zshenv
└─▶ ~/.config/environment.d/50-mesh.conf ◀─ read by the account's service manager
│
~/.zshrc: the mesh's block FIRST — defaults and the
three slots — then the operator's own lines, kept
```
**The environment module** (`node-env`) holds `node-environment`. It has no package and no process.
Its two files are written by the host from placeholders the controller fills.
**The shell module** (`zsh`) holds `node-login-shell`. It:
- installs the package;
- sets the login shell through the `user` shape;
- writes two blocks:
- one in `.zshenv`, sourcing the environment;
- one at the start of `.zshrc`, holding the defaults every machine shares today: the title, the
keybindings, the aliases and the two small functions, with the three slots in place;
- contributes its own environment: the editor, the configuration home, and `~/.local/bin` plus the
two script directories on `PATH`;
- serves `execute` and its own `zsh_config`.
**The prompt module** (`powerlevel10k`) ships the theme as a pinned vendored archive, and the prompt's
configuration as its own file in a directory it owns. It contributes the zsh code that loads both.
**Two plugin modules** (`zsh-autosuggestions`, `zsh-syntax-highlighting`) each install their
distribution package and contribute one line. Syntax highlighting goes in the `last` slot, which is
what its upstream asks for.
**What stays the operator's** is everything below the block in `.zshrc`, and `~/.zshrc.local`, which
the operator's lines source as they do today. On every machine today that means:
- the version manager's lines and the toolchain's `PATH` entry, until those modules exist;
- the two variables naming the operator's own script library;
- the agent's title variable;
- the port aliases;
- the workstation's desktop variables, which live in `~/.zshrc.local` already.
Nothing is lost at any step, because a line moves out of the operator's part only when a module
carries it.
**The migration is a person's act**, listed in the zsh module's documentation (ADR 0182): after the
first push, delete from `.zshrc` the lines the block now carries. Until then they run twice, which is
harmless and visible.
## Work packages
```
WP1 the host gives a login back (mesh-host) issue 228
WP2 the controller composes environment and shell code (mesh-controller)
WP3 the modules (mesh-catalog) needs WP2 to resolve
WP4 the service manager's module, finished (mesh-catalog) independent
WP5 assign and prove (operator-gated) needs WP1–WP3 merged and rolled
```
WP1, WP2 and WP4 are independent, and are built in parallel on one feature branch per repository
([playbook 07](../../00-META/process/07-feature-branches.md)). WP3 is written in parallel and proven
against WP2's controller before anything is published.
## WP1 — The host gives a login back
*mesh-host. Half a day. [Issue 228](../../04-ISSUES/228-a-login-the-mesh-set-is-never-given-back/00-report.md).*
**What changes.**
- The `user` shape records, in its applied record, the login shell it found whenever it changes it.
- Removing a `user` never deletes the account, whether or not the mesh created it. If the account's
shell is still the one the mesh set, and the recorded shell is still executable, the recorded shell
is set back. Otherwise the shell is left as it is, and the outcome says why.
- Before a shell is set, it is refused unless it is executable and listed among the machine's shells.
The exception is a shell that refuses logins (`nologin`, `false`): the distribution does not list
those, and the controller's own account uses one, so it need only be executable. The refusal fails
that resource and leaves the account untouched.
- A directory the host creates on the way to a file, a block or an archive inside an account's home
belongs to that account, the home itself included when the host makes it. A directory that was
already there keeps its owner and mode (ADR 0182). Until this, a fresh account's `~/.config` or
`~/.local/share` would have been created as root's.
- Giving the shell back is reported, never fatal. A failed `usermod` on removal is named in the
outcome and the record is dropped, because a fatal removal is exactly the wedge issue 228 is about.
**Proof.** The host's tests:
- an undeclared `user` no longer stops the apply;
- the found shell comes back;
- a shell changed by a person since is left alone;
- a missing shell is refused before `usermod` runs;
- a created account survives its removal.
## WP2 — The controller composes environment and shell code
*mesh-controller. One to two days.*
**What changes.**
- **The seat table.** It gains `node-environment` (node scope, no verbs) and `node-login-shell`
(node scope, the verb `execute`). A module may no longer declare a seat named `login-shell` or
`node-login-shell`. Both new seats are seeded into a live store by the existing additive seeding.
- **The manifest.** It gains two contribution fields, each refused at parse when malformed:
- `environment`, with `variables` and `path`: a variable name must be a POSIX name and not `PATH`;
a value may not contain `$`, a quote, a backslash or a line break; a path entry's place is
`start` or `end`;
- `shell`: each entry names a known shell, a known slot, and non-empty code.
- **Composition.** It fills `${environment:posix}`, `${environment:systemd}` and
`${shell:<shell>:<slot>}` in the claiming holder's file contents, from every module assigned to
the node. The rendering is ADR 0203's and ADR 0204's: module order, a naming line per contribution,
`PATH` entries added only when missing. `${machine:…}` in a contributed value is resolved first.
- **Refusals.** A variable set by two modules on one node is refused, naming both. A placeholder in a
module that does not claim the matching seat is refused, both at the catalogue check and at
composition.
**Proof.** The controller's tests:
- both environment renderings, byte for byte, from a fixed set of contributions;
- the POSIX rendering sourced twice by `sh` leaves `PATH` unchanged;
- slot order and per-shell filtering;
- each refusal, by name;
- the seat table carries both seats and refuses a module declaring either.
The catalogue check over the whole catalogue passes.
## WP3 — The modules
*mesh-catalog. One day.*
**What changes.**
- **`node-env`, new.** It claims `node-environment` and declares two owned files: the POSIX file at
the path the seat fixes, and the service manager's file, each holding its placeholder. It declares
no tools.
- **`zsh`, rewritten.**
- It drops its seat declaration and claims `node-login-shell`.
- Its environment moves to a contribution.
- It writes a `.zshenv` block that sources the environment file.
- Its `.zshrc` block goes at the start and carries today's shared defaults, with the three slots.
- It keeps the `user` shape.
- `execute` runs `zsh -lc` in the account's home, with the runtime's session words for the user
manager. Its timeout is bounded below the runtime's thirty-second call limit; its output is cut at
a bound and marked as cut; on timeout it kills the process group.
- Tests cover the tool over real child processes and the manifest's shape.
- Its documentation lists the one-off migration.
- **`powerlevel10k`, new.**
- The theme is vendored at a pinned upstream release, with its licence, as an archive artifact
unpacked into the module's directory under the account's home.
- The prompt configuration is today's file, as its own owned file in the same directory.
- It contributes the zsh code that loads the theme and the configuration.
- Today's file has the instant-prompt cache commented out, so the module does not turn it on.
- **`zsh-autosuggestions` and `zsh-syntax-highlighting`, new.** Each declares its package and
contributes its loader from the distribution's path, in the `normal` and `last` slots.
**Proof.** The controller's catalogue check over the whole catalogue passes. The modules' tests pass.
A rehearsal composition for a node holding all five shows:
- the `.zshrc` block with the prompt in `normal` and highlighting in `last`;
- the environment file with the shell's `PATH` entries;
- the service manager's file.
## WP4 — The service manager's module, finished
*mesh-catalog. Half a day. From the review of 2026-10-04.*
**What changes.**
- System-scope `start`, `stop`, `restart`, `enable` and `disable` escalate with `sudo -n` when the
runtime is not root, as the packet filter and intrusion modules do. They name a refusal by how it
failed.
- User scope reaches the account's manager by its runtime directory, which the runtime's environment
lacks.
- A failed `systemctl` is an error, not an empty list.
- The package resource goes: the service manager is always present, and it collided with the network
module's identical declaration on a machine running both.
- `status` says whether the mesh declares the unit. The restore note is attached only to such a unit.
- Tests cover a fake runner.
The user-scoped units of mesh-host #72 stay to-be 38's WP6.
**Proof.** The module's tests. Live, after WP5:
- `node-service-manager.units` answers in both scopes on a workstation and on a server;
- `restart` of a harmless unit answers `ok`.
## WP5 — Assign and prove
*Operator-gated. Nothing here runs without the operator's go-ahead.*
**Order.**
1. Merge WP1 and roll the host.
2. Merge WP2, and push the controller.
3. Merge WP3 and WP4, and build the new modules by hand: a new catalogue module's first build is asked
for, not automatic.
4. On one server, assign `node-env`, `zsh`, `zsh-autosuggestions` and `zsh-syntax-highlighting`, and
push. Then check:
- `node-login-shell.execute@<server> command="echo $PATH"` shows the shell's entries;
- `.zshrc` begins with the block;
- the operator's lines follow untouched;
- the environment file and the service manager's file exist.
5. The operator deletes the duplicated lines, per the zsh module's documentation.
6. The other server, then the two workstations, the workstations also with `powerlevel10k`.
7. Assign `systemd` everywhere, and prove WP4.
8. Unassign one plugin module on one machine. Its line leaves the block at the next push, and nothing
else changes.
The follow-up records to-be 38 names are still owed:
- what a shell module assigned beside the holder does;
- how a person's own environment variable is a setting rather than a line, once issue 168 closes.
@@ -0,0 +1,105 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog, mesh-controller, mesh-host]
updated: 2026-10-04
decisions:
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
- 02-DECISIONS/0040-what-a-module-is.md
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
- 02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md
- 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md
---
# 42. The machines' modules, in order
The order in which the modules of [research 026](../../01-RESEARCH/026-the-graphical-session-as-modules/00-overview.md)
and [research 027](../../01-RESEARCH/027-the-system-layer-as-modules/00-overview.md) are built and rolled
out, as the operator set it on 2026-10-04.
Three phases, in order, each built from the bottom up (the most core module first):
1. the modules every machine shares;
2. those both workstations share;
3. those of one machine model.
The shell came first ([to-be 41](41-the-shell-and-the-accounts-environment.md)). Each module's
definition, improvements and tools follow the research. A position that needs a new mechanism (a new
seat, a gated assignment, generalised contributions) gets its record when its first module needs it,
not before. Modules that need none go ahead now.
## How every module moves
1. Written in the catalogue, with its tools and their tests, and checked by the controller's catalogue
check.
2. Merged, which builds it.
3. Assigned to **the first workstation, the proving machine**, and pushed. Its tools and files are
proven there.
4. Then assigned to every other machine it applies to, and pushed.
The operator delegated the go-ahead for each step on 2026-10-04 ("non-important decisions, easily
reversed"). Each step is reported.
**Adopting is also improving** (research 026, 027 overviews): every module lists what it fixes over
today, and leaves no predecessor copy of what it now owns.
## Phase 1 — every machine
In order:
| | module | owns | improves |
|---|---|---|---|
| 1 | `sudo` | the operator account's escalation, as a drop-in it owns | declares what three modules' tools assume and nothing stated |
| 2 | `localization` | locale, time zone, console keymap | one machine on another zone and keymap |
| 3 | `time-sync` | timesyncd and its servers | two different daemons across four machines |
| 4 | `pacman` | the package manager's configuration, mirrors and their refresh, cache cleaning | mirrors generated once and never again; caches never cleaned |
| 5 | `logrotate` | the timer and base configuration | rotation running on one machine of four |
| 6 | `avahi` | the daemon | on all four, owned by none |
| 7 | `systemd` | the service manager's tools (to-be 41 WP4) | built, assigned nowhere |
| 8 | `docker` | the runtime's packages, base configuration, group | four configurations, one owner on one machine |
| 9 | `ssh-client` | everything under `~/.ssh` (research 027/03) | a predecessor's entries winning over the mesh's; stale keys |
| 10 | scripts | the operator's own scripts, shared and per role (research 027/03) | under no version control, copied by hand |
| 11 | `kernel` | kernel, microcode, boot entries | two machines without microcode |
`systemd`, `pacman` and `docker` hold the three seats that apply resources
([ADR 0207](../../02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)):
every module that declares a service, a package or a container depends on them being held on its node.
They go on every machine before the rest, and once they have, an unmet dependency is refused rather
than reported.
`kernel` is last because a mistake in it costs a boot. `docker` stays a module without the runtime seat
until ADRs 0165 and 0166 are accepted.
## Phase 2 — both workstations
In order:
1. `fonts`;
2. `xorg` with autorandr;
3. `lemurs`;
4. `i3`;
5. `xterm`;
6. the theme module;
7. `picom`, `rofi`, `dmenu`, `dunst`, the lock module, `xclip`, the clipboard manager, `feh` and
`i3status-rust`;
8. `gnome-keyring`;
9. `docker-compose`, `snapd`, `flatpak`, `cups`, `bluetooth`.
The seats, gating, contributions and session start are [ADR 0208](../../02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md).
## Phase 3 — one machine model
The laptop's hardware module (vendor daemon, GPU mode, charge limit, logind, brightness and vendor keys)
and `memory-pressure` (research 027/03).
## How it is checked
Each module's own tests and the catalogue check, at merge. On the proving machine, each tool answered
through the mesh and each owned file checked in place, before any other machine is assigned. This
document's tables are updated as each module lands.
+2
View File
@@ -43,6 +43,8 @@ document is written and this one's status becomes `implemented`.
| [`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) | | [`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) | | [`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) | | [`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) |
| [`41-the-shell-and-the-accounts-environment.md`](41-the-shell-and-the-accounts-environment.md) | **In progress.** The shell and the account's environment as modules: an environment module every module contributes variables and `PATH` entries to, shell code contributed to the login shell in named slots, the prompt and plugins as modules, the host giving a login back, and the service manager's module finished | [ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md), [ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md), [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md) |
| [`42-the-machines-modules-in-order.md`](42-the-machines-modules-in-order.md) | **In progress.** The order the machines' modules of research 026 and 027 are built and rolled out: every machine's first (sudo, localization, time sync, pacman, logrotate, avahi, systemd, docker, `~/.ssh`, scripts, kernel), then both workstations', then one machine model's; each proven on one workstation before the rest | [ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md), [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md), [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md) |
## Not yet written ## Not yet written
@@ -1,9 +1,9 @@
--- ---
status: open status: resolved
opened: 2026-09-23 opened: 2026-09-23
located-in: [] located-in: [mesh-controller internal/inventory, mesh-controller internal/artifacts, mesh-host internal/apply, mesh-catalog modules/distribution]
fixed-by: fixed-by: 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
amended-design: amended-design: 03-DESIGN/01-to-be/18-building-a-module.md
--- ---
# 108 — The registry has no garbage collection, and two doors make it harder to add # 108 — The registry has no garbage collection, and two doors make it harder to add
@@ -75,3 +75,32 @@ real thing services need, and the mesh cannot express one.
images by digest and moves by version — is retention "the digests no recorded build names"? images by digest and moves by version — is retention "the digests no recorded build names"?
- Who owns the routine when the store and its public door are two modules — the store, since the - Who owns the routine when the store and its public door are two modules — the store, since the
volume is its? volume is its?
## Answered, 2026-10-02 — [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)
The three open questions, answered:
- **A maintenance step, or a backend that does not need its writers stopped?** The step. A
scheduled container may name `while-stopped` — resource ids of **its own module's** containers,
which the host stops before the run and starts again after it whatever the step did. A storage
backend the mesh does not run would be a bigger thing to own than the mechanism it avoids, and
the mechanism is wanted anyway: a service that cannot have work done underneath it is a real
shape and the mesh could not express it at all.
- **Is retention "the digests no recorded build names"?** Nearly. An artifact stays because a
definition the mesh holds names it (no age limit), or because it belongs to one of the five most
recent successful builds of its module. Last-N-tags was the predecessor's rule for a registry
that knew nothing else; this mesh knows what each digest is for.
- **Who owns the routine now the second door is gone?** Both halves, each where it can be. The
**mesh** decides what may go — only it holds the records — and asks the store to drop it. The
**store** reclaims the bytes, because only it can stop its own server. Neither half can be done
by the other.
And the sharpened point — enabling deletion on a door with no accounts — dissolved on inspection:
**that door already accepts a push**, so a writer who can reach it can already replace any tag.
Delete takes nothing a push did not have. What it does not do is undo ADR 0082's bargain, which
putting an authenticated door in front of deletion would have.
The second registry process is not built, as the 2026-09-26 note says, so the shared blob cache
and the deletion-cached-by-the-other-door problem never arise. Plain `garbage-collect` is enough:
what the mesh keeps is still a manifest in the store, so `--delete-untagged` — the flag that would
delete images machines are running — is not needed at all.
@@ -2,7 +2,7 @@
status: resolved status: resolved
opened: 2026-09-26 opened: 2026-09-26
located-in: [mesh-controller internal/catalogue, mesh-sdk src/provisioner, mesh-catalog modules/minio] located-in: [mesh-controller internal/catalogue, mesh-sdk src/provisioner, mesh-catalog modules/minio]
fixed-by: 02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md fixed-by: 02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md
amended-design: 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md amended-design: 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
--- ---
@@ -64,7 +64,7 @@ compares it to what the provider will actually create. The one wrong instance wa
bucket in its own configuration against the one the provider would create is a check that could bucket in its own configuration against the one the provider would create is a check that could
exist today, for any interface, without the mechanism above. exist today, for any interface, without the mechanism above.
## Answered, 2026-10-02 — [ADR 0188](../../02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md) ## Answered, 2026-10-02 — [ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md)
The channel is the provider's own `serves` block, which may now name the consumer the mesh is The channel is the provider's own `serves` block, which may now name the consumer the mesh is
serving: `${consumer:as}` and `${consumer:as:dns}`. The mesh fills it once, where it knows who the serving: `${consumer:as}` and `${consumer:as:dns}`. The mesh fills it once, where it knows who the
@@ -1,8 +1,9 @@
--- ---
status: located status: resolved
opened: 2026-09-30 opened: 2026-09-30
located-in: [mesh-host internal/apply (no removal for an archive)] located-in: [mesh-host internal/apply (no removal for an archive)]
fixed-by: fixed-by:
- mesh-host#90
amended-design: amended-design:
--- ---
@@ -54,3 +55,15 @@ argue for elsewhere: the mesh gives back what it found.
A module with an archive is assigned, pushed, unassigned and pushed again; what the archive put on the A module with an archive is assigned, pushed, unassigned and pushed again; what the archive put on the
machine is gone, anything that was in the directory beforehand is still there, and the apply that machine is gone, anything that was in the directory beforehand is still there, and the apply that
removed it applied everything else in the same declaration. removed it applied everything else in the same declaration.
## Resolved
*2026-10-04.* Hit again live the same day: a race between two pushes delivered a declaration without
a just-assigned module, and removing its tools bundle stopped a workstation's apply until the next push.
mesh-host#90 records what an archive unpacked: its files, the directories it made, whether the host
made the target and its parents. Undeclaring removes exactly those, never a file it did not place and
never a directory that was there before, and a failed removal is reported, never fatal.
An archive recorded before the change learns its list from its own bytes on the next apply while it is
still declared. One already orphaned is left in place, said and forgotten. Applying an archive no longer
deletes files the mesh did not place in its target directory (ADR 0030).
@@ -0,0 +1,85 @@
---
status: located
opened: 2026-10-01
located-in: [mesh-catalog modules/dnsmasq, mesh-controller internal/overlay/generator.go, mesh-controller internal/catalogue/resolve.go (checkResources)]
fixed-by:
amended-design:
---
# 190 — The container runtime's configuration is written by modules that are not the runtime's
## What was observed
The runtime's configuration file and its service are declared by two parties, neither of which is
the runtime:
- **The resolver module** writes the runtime's `dns` key (the machine's private address) and
`live-restore` into the runtime's file, written into rather than over
([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md)). It also
declares the runtime's service, reloaded when that file changes. The `dns` key has been written
since the resolver module was converted from its predecessor on 2026-09-23; `live-restore` and the
service were added on 2026-09-30 while fixing
[issue 110](../110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md),
where containers silently resolved through a public resolver.
- **The private network** writes the runtime's `insecure-registries` into the same file, and declares
the same service reloaded on it, as [ADR 0082](../../02-DECISIONS/0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md)
and ADR 0102 decided. The controller generates both resources per machine.
On the three machines that run the resolver module, both declare one path and one unit. Nothing refuses
it. The collision check compares the resources of catalogue modules. The private network is computed,
so its resources are produced when a machine's declaration is composed, and the check never sees them.
The machine without the resolver module shows the other half. Its runtime still has the predecessor's
resolver and `live-restore` off, because the only module that sets them is a DNS server. A machine
gets a correct container runtime only as a side effect of being given a resolver.
> **Later the same day, 2026-10-02.** The resolver module and its sibling for the resolver file were
> assigned to the fourth machine ([issue 198](../198-the-lans-dns-server-ran-outside-the-mesh-and-its-filter-closed-it/00-report.md)),
> so all four now have the resolver writing into the runtime's file, and the predecessor's
> `live-restore: false` there is gone. The same work made the runtime's file, as the resolver declares
> it, take no settings: a setting meant for the resolver's own configuration had reached it. The
> collision and the ownership question above are unchanged.
## Why this is here
The operator ruled it a defect, not a design: **a module does not write another software's
configuration.** The need behind each write is real. Containers must resolve the mesh's names
([ADR 0148](../../02-DECISIONS/0148-the-meshs-names-are-resolved-not-copied-into-containers.md) step 2).
A daemon restart must not stop every container. Every machine on the network must trust the mesh's
registry. But each of these is a fact the runtime must be *given*, and the module that gives it is the
runtime's own. With three writers, nobody can say what the file should contain. Two of the facts are
reloaded when one of them needs a restart (issue 110's first fault). And the moment a module for the
runtime exists, it is refused on every machine with the resolver, or, through the private network's
path, accepted without anyone noticing a collision.
## What resolves it
[ADR 0166](../../02-DECISIONS/0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)
gives the runtime a module that holds its seat and owns its file and service.
[ADR 0164](../../02-DECISIONS/0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md)
gives that module declared settings with defaults. The fix, once both are accepted:
1. The resolver module drops its runtime file and runtime service. It knows nothing of the runtime.
2. The private network stops generating either resource. ADR 0082's decision stands — being on the
network is what grants the trust, and no module author is involved — and only *who writes it*
moves. The mesh gives the registry to the runtime module as a value. ADR 0082 and ADR 0102 each
get a dated note saying where their mechanism now lives.
3. The runtime module writes `dns`, `live-restore` and `insecure-registries`, each a declared
setting with its cost: `dns` costs a restart, which `live-restore` makes harmless.
4. Steps 1–3 land in one push. A runtime module declaring the file beside a resolver module still
declaring it is refused.
5. The collision check sees a computed module's resources as well, so a second writer cannot come
back through generated code.
## Open questions
- **How the resolver's address reaches the runtime.** Either the resolver seat (`node-dns-resolver`)
delivers an address its holder serves, or the runtime module reads a machine fact and the seat
being held is only a precondition. The first tracks a resolver moving off the private address. The
second needs nothing new.
- **What `dns` defaults to on a machine with no resolver seat held.** Nothing, leaving the runtime's
own behaviour, is the honest default. A public resolver hides exactly the failure issue 110 took a
day to find.
- **The adopted machine's predecessor values.** The runtime module adopting a file with a
hand-written `dns` and `live-restore: false` replaces both. That is intended, and is the one
restart the operator must make on that machine.
@@ -0,0 +1,78 @@
---
status: open
opened: 2026-10-02
located-in: [mesh-catalog modules/mesh-console, mesh-controller cmd/mesh-controller/plan.go (port assignment)]
fixed-by:
amended-design:
---
# 192 — The mesh's tools reach a person only by a registration made by hand
## What was observed
A design session on a workstation had none of the mesh's tools. The console was running on that
machine and answering on its loopback port. It was reached over the bus as the console's account, and
listed every running module's tools and every seat's verbs
([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)). What was missing
was the registration that tells the person's coding agent where the console is. That registration had
been made by hand, once, while migrating the machine, and scoped to the one project directory it was
made in. Every session started anywhere else had no mesh tools. Nothing said so: the agent simply
offered no mesh tools, and the session fell back to a pull-request link for a person to open by hand.
The predecessor did this job itself: it wrote its tool server into the agent's user configuration on
every machine. Migrating removed that entry, as it should have, and no module took the job over.
## Why this is here
Three gaps, each of which would have stopped a module from doing it even if one existed.
**1. The console tells nobody where it is.** Its definition listens on a port and provides nothing.
A module that wanted to point an agent at the console has no requirement it could name, so it
would have to write the address into its own definition as a literal. That is exactly what
[ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) and
[ADR 0155](../../02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md)
remove.
**2. The console's port is one its definition chose.** The definition names a port, and the mesh
never assigned one: no port assignment exists for the console on any machine. The plan assigns a
machine port only to a port a container publishes through a mapping, "without one the software binds
what it binds". The console runs on the host network with no mapping, but it reads its listening
address from `${port:…}`, so the mesh could move it and does not. That is a module choosing a
machine port, which [ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md) exists to
prevent, through a gap in how the rule is applied rather than a decision against it. A module that
reads its port from the mesh should be assigned one like any other.
**3. Nothing in the mesh owns a person's agent configuration.** No catalogue module writes the agent's
settings, its tool-server registrations, or the rules and skills the predecessor delivered. On the
four machines these are hand-kept, or left over from the predecessor, or missing.
## What a fix looks like (not decided)
- **The console provides its endpoint.** A provision, working name `mesh-tools`, served as the URL on
the machine port the mesh gives it. The console listens only on loopback, so the provider must be on
the consumer's own machine. Co-location already chooses it
([ADR 0084](../../02-DECISIONS/0084-which-provider-serves-a-consumer.md)), and a machine with no
console refuses the consumer, naming the provision.
- **A module for the coding agent requires it** and writes the registration into the agent's
system-wide managed settings. The agent reads tool servers from a `managedMcpServers` key there. That
file is the machine's rather than a user's, so the module owns it whole and no home directory is
named. People keep their own registrations beside it. The agent's separate *exclusive* managed
file is the wrong one: it blocks every registration a person makes and hides the hosted connectors.
The agent's per-user file is rewritten by the agent continuously and sits in a home directory,
which would make its path an operator value. These facts come from the agent's documentation
(managed MCP and managed settings pages), not yet verified on a machine.
- **The same module owns the rest of the agent's configuration** the predecessor delivered: managed
settings and the rules, skills and instructions every session reads. Each declared setting carries
a default (ADR 0164,
proposed on its own branch), so one configuration serves every machine and one machine may differ.
## Open questions
- **Is the agent's configuration one module or several?** Tool registration, managed settings, and
the instruction files have different readers and change at different rates.
- **Whose machine port is the console's?** Should a host-network container that reads its port from
`${port:…}` be assigned one, or should a machine-only listener keep its declared number? The second
needs a decision, because ADR 0038 does not allow it today.
- **Credentials.** The console's authority is the machine's login (ADR 0152). A registration that
reaches it carries no secret today. If the console ever listens beyond loopback, the registration
needs one, from the vault.
@@ -0,0 +1,112 @@
---
status: resolved
opened: 2026-10-02
located-in: [mesh-catalog modules/postgres/client.ts (readOnlyQuery)]
fixed-by: [mesh-catalog PR 209 (postgres), mesh-catalog PR 210 (mssql)]
amended-design:
---
# 193 — The store seat's read-only query is read-only by convention, and its answer is unreadable
## What was observed
Asking the store seat's `query` verb for a count through the console returned this. Rows are each
wrapped in an object under a key named `BEGIN`: the column name, then the value, then the word
`ROLLBACK`. A query returning nothing gave the column name and `ROLLBACK` alone. The answer to
`select count(*) as n from <table>` was:
> `rows: [ {BEGIN: "n"}, {BEGIN: "46"}, {BEGIN: "ROLLBACK"} ]`
A reader can work it out. A program cannot, and a query with two columns loses which value belongs to
which.
## Why this is here
**The cause is the same line that makes the query read-only.** The holder's tool sends
`BEGIN TRANSACTION READ ONLY; <the caller's statement>; ROLLBACK;` to the command-line client as one
string. The client prints a command tag for each of the three statements, and the parser takes the
first line, `BEGIN`, as the header.
**And it is not read-only.** [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
decided the store seat's `query` verb is "one read-only statement against one database". The only
thing enforcing that is the wrapping transaction, and the caller's statement is pasted inside it as
text. A statement that begins by ending the transaction (a commit, then anything) runs whatever
follows it outside the read-only transaction, with the holder's own role, the administrative one that creates every
consumer's role and database. A rule stated in a decision and enforced by string concatenation is enforced by nothing.
*This is read from the code, not tried against the live store, and it should not be tried there.*
The lab bed is where it gets proven.
Every caller with `invokes` on the store seat's `query` can do this. The console has `invokes: ["*"]`,
so that includes anyone logged in on a machine running the console.
## What a fix looks like
- **One statement, refused otherwise.** Send the caller's statement alone, through the client's
single-statement path (the extended protocol takes one statement per call and refuses more). The
read-only property then comes from the session, not from text around the statement.
- **Read-only by role, not by transaction.** Run the verb as a role that can only read, granted
`pg_read_all_data`, not as the administrative role. A statement that escapes every wrapper still cannot write.
- **Rows as rows.** Parse the client's output with the column names it returns, or use a driver
instead of the command-line client, so a row is an object keyed by its columns.
- **The check 0159 lacks:** a test that sends a commit followed by a write and asserts the write is
refused and nothing changed. Another asserts a two-column row comes back keyed by both columns.
## Proven, 2026-10-02
On a throwaway server — the same engine image, no network, reached over a socket — the module's code
from the catalogue's main branch ran `COMMIT; COPY (select 1) TO PROGRAM '<a command>'` and **the
command ran on the database host** as the server's own user. `COMMIT; DROP TABLE t` executed the drop
outside the read-only transaction; the wrapper's own trailing rollback happened to undo it, which a
caller ending their statement with a commit of their own would get past (not tried). Nothing was tried
against the live store.
The fix (mesh-catalog PR 209) runs the caller's statement as a login granted `pg_read_all_data` and
nothing else, read-only by its role and its session, with a password the mesh mints as one of the
module's own secrets; without that password the call is refused rather than run as the admin. On the
same throwaway server every escape above, and `SET ROLE`, `RESET SESSION AUTHORIZATION`, turning
read-only off, creating a table, altering the role and reading a server file, is refused; a plain
select comes back keyed by its columns. One attempt — turning the transaction's read-only off, then
deleting — got past the first layer and was stopped by the second, which is why both exist.
**Not answered by the statement-count fix proposed above.** The command-line client sends one string
in one message, so several statements still arrive together. They are harmless as the reader, and
refusing them is left to whoever moves the module to a driver.
## The same hole, elsewhere — and two worse ones
The `mssql` module wrapped a caller's statement the same way (`BEGIN TRANSACTION; … ROLLBACK;` as its
administrator) for its `mssql_query` tool. Its command-line client added two holes of its own. Both
were proven on a throwaway server, running the client the way the module ran it:
- **It substitutes `$(NAME)` from its environment into the caller's text**, and the administrator's
password is in that environment. Selecting it as a string returned the password.
- **It reads a line beginning `:!!` as a command that starts a program**, in the container that holds
the administrator's password and the module's bus credentials. Its switch for refusing such commands
makes the shipped version ignore the statement entirely, so the switch cannot be the guard.
None of it was reachable on the live mesh, for a reason that is a defect of its own: the runtime image
never installed the client, so every mssql tool failed (`spawn sqlcmd ENOENT`). The fix (mesh-catalog
PR 210) installs the client at a pinned digest and runs the caller's statement as a login that can
connect and read and do nothing else. Substitution is off. The statement must be one line, placed after
the module's own text, so no line of it can begin a command; a line break is refused before the client
starts. On the throwaway server, writes, `xp_cmdshell`, impersonating the administrator, and joining
the administrators' role were all refused, and the variable came back as the literal text.
**The general lesson**, worth more than either module: *a command-line client is an interpreter with
its own syntax, and a caller's text handed to it is a program in that syntax as well as in SQL.* A
module that passes a caller's text to a client has two languages to defend, and a transaction drawn
around the text defends neither.
## Resolved, 2026-10-02
Both pull requests merged, built and pushed to the two machines that run each module. Checked live, on
every copy, by asking each one who it is:
- the store seat's `query`, and postgres's own tool on each machine, answer as the reader login —
not a superuser, in a read-only transaction — with rows keyed by their columns;
- mssql's tool, on each machine, answers as its reader login, outside the administrators' role, and
returns `$(SQLCMDPASSWORD)` as the literal text it is. Its tools work for the first time.
The escapes themselves were tried only on the throwaway servers above; on the live mesh the check is
the identity a statement runs as, which is what makes every escape a statement that the login cannot do.
@@ -0,0 +1,93 @@
---
status: open
opened: 2026-10-02
located-in: [mesh-catalog modules/dnsmasq, mesh-controller cmd/mesh-controller]
fixed-by:
amended-design:
---
# 202 — A module whose required setting nobody set is left out of the machine, and the resolver is the module it happened to
## What was observed
Running the controller's own test suite against the catalogue beside it, 2026-10-02.
`TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves` fails with *"the resolver
was not handed the machines"*. Composing the same machine by hand and listing what it receives
shows why: **dnsmasq contributes nothing at all.** Four resources are composed for that node, all
of them the overlay's. The resolver's package, its configuration, its service and the fact that
carries every machine's name are simply not there.
The cause is one line added to `dnsmasq`'s configuration earlier the same day: the addresses it
listens on beside the machine's own became an operator setting,
`listen-address=${setting:listen-addresses}`, with no default. A `${setting:…}` nothing sets is
refused, a module that cannot be composed is **left out** rather than failing the whole machine
([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md)), and so a node
assigned the resolver is handed a declaration with no resolver in it.
The failing test is the symptom that surfaced it. The test is not what is wrong.
**Proven rather than inferred.** Composing the same machine a second time with
`listen-addresses` set to `127.0.0.1` and nothing else changed, every one of dnsmasq's eight
resources appears — `needs-broker`, `mesh-state`, `package`, `config`, `runtime-dns`, `runtime`,
`service` and `fact-node-zones`. The only difference between a machine with a resolver and a
machine without one is whether somebody set a value that did not exist yesterday.
## Why it matters beyond this instance
**Leaving a module out is right, and being quiet about it is not.** The rule exists so one
module's broken setting cannot stop a machine converging — a good rule. But the outcome here is a
machine that applies cleanly, reports current, and is missing its DNS resolver. Every name on that
machine then resolves through whatever was there before, or not at all, and nothing in the mesh
says the resolver was dropped. That is the shape
[issue 152](../152-a-nodes-plan-failure-silently-drops-its-routed-names/00-report.md) records for
routed names, here for a whole module.
**And a setting with no default is a definition that cannot be assigned.** Every other
`${setting:…}` in the catalogue names something that is genuinely particular to one installation —
a public domain, an issuer. "Which addresses besides my own do I answer on" has an obvious correct
default for every machine that is not a LAN gateway: none beside loopback. A definition that
refuses to compose until somebody sets a value most machines do not need is a definition that
breaks the next node to be assigned it, and genesis with it.
## What this does not claim
Whether the live machines are affected was not checked — those four have had the setting set, or
their resolvers would already be gone. The claim is about a machine assigned the resolver *from
now on*, and about the silence.
## Open questions
- Should the declaration say which modules it left out, where a person or the console can see it?
`left_out` already travels to the host ([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md));
what is missing is anything that reads it back and says so.
- ~~Should a `${setting:…}` be allowed a default?~~ **Decided in principle and not built.**
[ADR 0164](../../02-DECISIONS/0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md)
(proposed, 2026-10-01) says a setting with a default is a tunable and one without is the
operator's, and narrows 0155's refusal to exactly the second. `listen-addresses` is a tunable by
that rule, and dnsmasq declares no settings block at all. So this issue is, in part, 0164 waiting
to be built — and in part the silence, which 0164 does not address.
- Is leaving a module out ever right for a module a node is **assigned**, as opposed to one it
merely pulls in? An assignment is somebody saying *this machine runs this*; silently not running
it is the one answer nobody asked for.
## Still true on 2026-10-04, and the evidence had to be re-taken
Re-checked after the bundles refactor landed (fourteen records, ADRs 0188 and 0190–0200). **The
fault stands and the old evidence no longer reaches it.**
`TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves` still fails on
mesh-controller main, with the same message — and now for a *different first reason*. `assign` is
refused before composition ever happens:
> dnsmasq on anchor has no bus credential: nothing was issued for anchor.dnsmasq … (novox/hq issue 203)
That is [issue 203](../203-a-fresh-assignment-is-pushed-before-its-credential-exists/00-report.md)'s
new guard doing its job on a test harness that mints no credential. Two faults are stacked in one
failing test, and the second was invisible behind the first.
Proven again, past both: mint `anchor.dnsmasq` so the assignment stands, then compose the machine
twice. **Without `listen-addresses` the node composes four resources, all the overlay's. With it
set to `127.0.0.1`, all eight of dnsmasq's appear** — `needs-broker`, `mesh-state`, `package`,
`config`, `runtime-dns`, `runtime`, `service`, `fact-node-zones`. Nothing else differs.
The test is now wrong about two things and should be fixed with whichever of these is fixed first.
@@ -0,0 +1,45 @@
---
status: resolved
opened: 2026-10-02
located-in:
- mesh-controller
fixed-by: mesh-controller PR #233
amended-design:
---
# 203 — A fresh assignment is pushed before its credential exists, and the runtime crash-loops
## What was observed
2026-10-02, the first live assignment of the node tools runtime (to-be 38 WP3). `assign` put the
module on a machine and `push` sent the declaration. The host applied everything: the bundle unpacked,
the unit written and started, the module's `broker` secret file written and owned by the operator
account. The runtime then restarted thirteen times in a minute:
```
mesh-tools: cannot read the broker credential at …/broker: SyntaxError: Unexpected token 'O',
"Oj6j2Ssa-v"... is not valid JSON
```
The file held a 40-byte random secret, not a bus credential. The push's own output had said why,
one line among forty: *the bus's user list leaves out … `<node>.node-tools`. Each is a user that cannot
connect until one is issued.* The credential exists only after `module issue <module> --node <node>`,
a separate act; a second push then carried the real credential and the runtime came up. The same
sequence repeated on the next two machines, by hand, in the right order.
## Why it matters beyond this instance
Every module that speaks on the bus declares an `own-secrets.broker`; the mesh seals *something*
there on assignment and the real credential only on issue. So the first push of any fresh assignment
delivers a process that cannot authenticate and will crash-loop until a person runs a second verb and
a second push. Nothing refuses the first push, and the warning is a line in a long list that is
printed on every push regardless. The design says the mesh issues an assignment's subjects and the
runtime serves what it is issued ([ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md));
an assignment whose credential is not issued is half an assignment, and the mesh lets it through.
## Questions HQ must answer
- Is issuing the credential part of assigning, so `assign` mints it, or must a push refuse a module
whose bus user is unminted, naming the verb?
- Is a placeholder sealed where a credential belongs ever right, or should the resource be absent
until the credential exists, so the host never writes a file the process cannot read?
@@ -0,0 +1,18 @@
# Diagnosis — 203
**2026-10-03.** Two acts, one effect. `assign` records the assignment and, at composition, every
`own-secrets` entry a module declares gets a sealed value from the controller's `Needed` map — a
value minted so the file exists, which for `broker` is a random secret, not a credential. The bus
credential is composed only by `module issue <module> --node <node>` (cmd/mesh-controller/modules.go,
`issueOnTheNewBus` → `issueWith`): it mints the bus user, records its hash, and seals the credential
JSON into the same `broker` need. Nothing joins the two: `push` composes and sends whatever the need
holds, and the only warning is the standing line listing every bus user without a minted credential,
printed on every push regardless of what was just assigned.
Ruled out: the host (it wrote the file it was given, owned as asked); the runtime (it refused a file
that is not JSON, correctly, and said so); the manifest (`own-secrets.broker` is the shape every
module uses).
**Owner:** mesh-controller — the assign path. **Fix direction:** assigning a module that declares
`own-secrets.broker` issues its credential in the same act, idempotently; a push of a module whose bus
user is unminted is refused by name rather than sent with a placeholder.
@@ -0,0 +1,49 @@
---
status: resolved
opened: 2026-10-02
located-in:
- mesh-controller
fixed-by: mesh-controller PR #232
amended-design:
---
# 204 — A controller handover re-sent every node a declaration composed from a stale view
## What was observed
2026-10-02 21:29 UTC. Two machines were assigned the node tools runtime and had the console taken off
them, and were pushed; the host on each applied it (the console removed, the runtime created and
running). Two seconds later, on each, a second declaration arrived that undid it — the host's log:
```
23:29:18 created node-tools.runtime (node-tools): 239 file(s), running as node-tools.service
23:29:18 applied 569 resource(s)
23:29:20 removed node-tools.runtime (node-tools)
23:29:20 forgotten node-tools.interpreter (nodejs)
23:29:24 created mesh-console.needs-broker, created mesh-console.server (mesh-console)
```
The second declaration had the console assigned and the runtime absent: the assignments as they were
a minute earlier. The controller's status knew of one send per machine, the person's. At that minute a
plan from an unrelated merge was rolling a new controller build onto the control node, so an instance
was starting while another was stopping. A third push by hand, two minutes later, restored both
machines and nothing undid it again.
Which instance sent the stale declaration, and from what, is not established: the outgoing one on its
way down, the incoming one at start-up before its view was current, or the rolling plan sending what it
had composed when it was made — the same family as [issue 201](../201-a-push-recreated-the-controller-behind-the-row-its-successor-wrote/00-report.md),
where a plan sent a digest older than the one a successor had written.
## Why it matters beyond this instance
A declaration the mesh sends is the mesh's word on what a machine should be; a machine applies it in
full, including removing what it no longer names. A stale one is therefore not a no-op: it tears down
whatever was assigned since, and the controller's own record does not show the send, so the next
person reads "applied, current" over a machine that is wrong. Two people merging within a minute is
ordinary, and a controller handover happens on every controller merge.
## Questions HQ must answer
- Where does the stale view come from, and is every send recorded so status can show it?
- Should a declaration carry the assignment generation it was composed from, so a host refuses one
older than the last it applied, as it already refuses a declaration it cannot verify?
@@ -0,0 +1,37 @@
# Diagnosis — 204
**2026-10-03, in the controller's code.**
Ruled out: a start-up re-send (a starting controller sends nothing; it asserts the bus, resumes plans,
follows events); a plan sending a recorded declaration (a plan records artifact digests and module
states, and its rollout composes fresh at send time); a cache (every compose reads the store); a path
that does not record its send (push, the cascade and the rollout all record after sending; only the raw
`declare <node> <file>` command did not); the host applying an older sequence (it already refuses a
declaration numbered below the one it kept).
Found, two faults that together produce the evidence:
1. **The sequence number went on at send time, after composing.** Every sending path composed first and
numbered each declaration as it was sent; a multi-machine send composes every machine before sending
any. So a declaration composed *before* an assignment changed and sent *after* a fresher one carried
the higher number — and the host, refusing only lower numbers, applied the older content as the mesh's
newest word. The stale declaration was accepted, so its number was higher, so it was composed earlier
and sent later.
2. **The send record was written on the sender's own context, after the send.** A controller being
replaced in that second has its context cancelled between telling the machine and writing the record;
the machine was told, the record never written, and status showed only the person's earlier send.
The likely sender, consistent with both and with the timing: the outgoing controller's reaction to a
catalogue registration during the build round, which re-sends the machines running the registered
module (one ran on exactly the two machines affected), composed under its hold before the person's
assignments, numbered and sent at 21:29:19–20 as the controller was being replaced. The old container's
log is gone, so the sender is inferred from code and timing; the mechanism is not.
**Owner:** mesh-controller. **Fix direction:** number a declaration before composing it, in every path,
so what was composed earlier is numbered lower whatever order the sends happen in and the host's
existing refusal does its job; record a send on a context that outlives the sender; the raw `declare`
command records too.
Two questions left for HQ: whether status should show the sequence a machine was last sent beside the
digest, and whether a sender's hold should also cover the assignment verbs, which today run between a
hold's compose and its send without waiting.
@@ -0,0 +1,47 @@
---
status: resolved
opened: 2026-10-02
located-in:
- mesh-host
- 00-META/how-we-build.md
fixed-by: mesh-host PR #79 (the host's half; who keeps a machine current is still a decision to take)
amended-design:
---
# 205 — A package resource fails against a stale package database, and nothing keeps it fresh
## What was observed
2026-10-02. The node tools runtime's manifest declares the interpreter as a package. On three machines
the package installed. On the control node the host failed the declaration three times and reported the
machine wrong, stuck:
```
applying "node-tools.interpreter": installing nodejs: pacman exited 1:
error: failed retrieving file 'nodejs-26.5.0-1-x86_64.pkg.tar.zst' from <mirror>: 404
… (every mirror)
error: nodejs: signature from "<packager>" is invalid
```
The machine's package database was from 24 July, ten weeks earlier; the mirrors had long moved on from
the version it asked for, and its keyring was as old. The host asks the package manager to install from
whatever database the machine has and does not refresh it; refreshing on the host's own initiative is
not safe either, because on a rolling distribution a refreshed database plus a single install is a
partial upgrade, which the distribution warns against. The way out was a full system upgrade by the
operator, outside the mesh.
## Why it matters beyond this instance
A `package` resource is one of the host's shapes and every environment module leans on it (design 37).
Its success depends on a machine fact the mesh neither records nor keeps: how old the package database
is. A machine that has not been upgraded in months fails every new package the mesh declares, with an
error that reads as a mirror outage. The mesh says the operator's machine is the mesh's
([ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md));
nothing in it says who keeps the machine current enough for its own declarations to apply, or checks.
## Questions HQ must answer
- Is keeping a machine's package database and keyring current a module's job (a `package-manager`
seat holder with a schedule), the host's, or the operator's — and how is it checked?
- Should a `package` resource's failure distinguish "the database is stale" from "the mirror is down",
so the report says what to do?
@@ -0,0 +1,18 @@
# Diagnosis — 205
**2026-10-03.** The host's package step on an Arch machine installs with the package manager against
the database the machine has (mesh-host internal/system/arch.go); it neither refreshes it nor can
safely, since a refresh plus one install is the partial upgrade the distribution warns against. On
the control node the database and keyring were from 24 July; the mirrors no longer served the version
it named, so every mirror answered 404 and the one cached file failed its signature. The host reported
the package manager's output whole, which reads as a mirror outage.
Two owners. The **narrow** half is the host's: classify that failure and say what it is — the database
is stale, the operator must upgrade — rather than relaying forty mirror lines. The **wide** half is a
rule nobody has written: who keeps a machine current enough for its own declarations to apply, and
how that is checked. ADR 0173 makes the machine the mesh's; `00-META/how-we-build.md` says nothing
about its package database. That is a decision (playbook 02), not a code fix: a `package-manager` seat
holder with a schedule, the host, or the operator by rule.
Ruled out: the manifest (`package: nodejs` is correct for the distribution and installed on three
machines the same hour); the network (the mirrors answered, with 404s).
@@ -0,0 +1,57 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-controller
fixed-by: mesh-controller PR #233 (the worker's shape and the plan's order); a credential older than its shape is re-issued by hand, not detected
amended-design:
---
# 206 — A seat's worker changing type strands its holder, and the build that would fix it
## What was observed
2026-10-03, the switch to shared build work ([ADR 0190](../../02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md)).
The controller change made a seat's worker a pull consumer and the build machine pull from it. The
merge's plan put the **build machine** in tier 0 and the controller in tier 1, so the new build machine
rolled first, onto a bus where the running controller had defined the worker as push:
```
mesh-builder: this machine cannot take work from mesh-build-machine: nats: cannot pull subscribe to
push based consumer. The mesh creates that queue and this machine's worker on it, and a build
machine may not create one
```
It restarted every few seconds. The old controller kept asking for tier 1 — the new controller image —
on that worker, and nothing took it. The only thing that would redefine the worker is the controller
that could not be built; there is no verb to run a module's previous build. The way out was a
person running the previous build machine image by hand on the control node until the new controller
had rolled, then removing it.
Two smaller faults surfaced on the way and were each a step of the same handover: the build machine's
credential, issued on 2026-09-28, carried no `claims`, so the new binary's "serve the seat your
credential claims" fell back to the new seat it had no grant for (re-issuing the credential fixed it);
and the build machine's container restarts on its environment file, not on its credential, so the
re-issued credential reached it only because it was already restarting.
## Why it matters beyond this instance
A consumer's type is part of the contract between the controller that defines a worker and the holder
that binds it, and the two are built and rolled by different plans in an order the dependency graph
decides, not the contract. Any future change to a worker's shape — ack wait, filters, type — can strand
every holder the same way, and when the holder is the build machine, the mesh cannot build its way out.
[ADR 0162](../../02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md) orders tiers by
artifacts; it says nothing about what must be *running* before what, and
[issue 201](../201-a-push-recreated-the-controller-behind-the-row-its-successor-wrote/00-report.md)
is the same gap seen from the controller's side.
## Questions HQ must answer
- Does the controller own the worker's shape fully — redefining an existing consumer to the shape it
derives on every start — or does a holder bind whatever shape it finds? Either answer must hold
across a handover where the two are at different versions.
- Should a plan that changes the build machine always roll the controller first, or should the mesh
keep a way to run a module's previous build without a person on the machine?
- A credential issued before claims existed names none: should the mesh re-issue credentials whose
shape is older than what the binary reads, or should every holder treat an unclaimed credential as
the seat its manifest claims?
@@ -0,0 +1,26 @@
# Diagnosis — 206
**2026-10-03.** Three faults in one handover, all the controller's.
1. **The worker's shape is asserted, not reconciled.** The controller creates a seat's worker if
absent (internal/broker, the consumer assertion on start) and leaves an existing one as it is. A
change of shape — here push to pull — therefore never reaches a bus that already has the worker
until somebody deletes it. The new build machine bound a worker whose type its code no longer
speaks.
2. **The plan rolled the holder before the definer.** The merge's plan tiered by artifacts (ADR 0162):
the build machine's image stands on nothing of the controller's, so it came first. For every other
module the order is indifferent; for the holder of the build seat, the controller that defines its
worker must run first, or the build that would bring the controller cannot be taken.
3. **A credential older than its shape.** The build machine's credential was sealed on 2026-09-28,
before credentials carried `claims`; the new binary read none and fell back to the new seat, for
which it had no grant. Re-issuing the credential fixed it; nothing had said it was stale.
Also seen: the build machine's container restarts on its environment file and not on its credential,
so a re-issued credential reaches it only by chance (shared with issue 203's fix direction).
Ruled out: the bus (it refused exactly what the grants and the consumer type said to refuse); the
build machine's new code (it did what its credential told it).
**Fix direction:** the controller reconciles every consumer it owns to the shape it derives, recreating
one whose type changed and saying so; a plan rolls the controller before any holder of the build seat;
a credential whose shape predates what the binary reads is listed and re-issued.
@@ -0,0 +1,49 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-controller
fixed-by: mesh-controller PR #233 (a re-made worker on a history-keeping stream delivers from now on); the two questions on retention and on outcomes for commits already passed stay open for a decision
amended-design:
---
# 207 — A re-made worker replayed every ask the stream kept, and the mesh re-registered its past
## What was observed
2026-10-03, recovering from [issue 206](../206-a-seats-worker-changing-type-strands-the-holder-and-the-build-that-would-fix-it/00-report.md).
The build seat's worker, a push consumer the new controller could not change to pull, was deleted by hand
and the controller restarted. On start it recreated the worker in the shape it derives — pull — and with
the delivery policy a new consumer gets when nothing says otherwise: *every message the stream holds*.
The seat's stream keeps its history. The build machine then took, in order, every build ask since
1 October:
```
a build request arrived for …/mesh-catalog.git (build-1790856308080864567)
[built] baserow from 6afc1160
```
Each outcome was heard and taken in as any build's is. In the minute before the build machine was taken
off the control node to stop it, nine modules were re-registered from the 1 October commit — baserow,
cloudflare-dns, gitlab, grafana, icecast, influxdb, jira, keycloak, letta — the catalogue's recorded
head moved back to that commit, so status listed almost every module as "behind", and the upgrade
policy re-sent the machine running five of them, which replaced their tools containers with the old
images. Recovery: the nine rebuilt from main by hand, the worker re-made by hand as pull delivering only
new asks, the build machine assigned again.
## Why it matters beyond this instance
A work queue that keeps its history is a replay waiting for a consumer that starts from the beginning,
and a new consumer starts there unless told not to. Nothing in the controller's consumer derivation says
where a seat's worker starts, so any re-creation — by hand, or by the reconciliation issue 206's fix
adds — can replay the mesh's whole build history into the catalogue and onto machines. And the taking-in
of an outcome trusts the outcome's commit absolutely: an outcome for a commit older than what the
catalogue holds is registered as if it were news, and rolls out.
## Questions HQ must answer
- Does a seat's work queue keep acknowledged asks at all? If it is a work queue, acknowledged work
should leave it ([design 25](../../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 calls it one); if it
keeps history for the record, its worker must be derived to start at new messages, always.
- Should a build outcome for a commit the catalogue has already moved past be recorded and **not**
registered — a build of the past is a fact, not a change — and never sent to machines?
@@ -0,0 +1,13 @@
# Diagnosis — 207
**2026-10-03.** The worker was deleted by hand and the controller, on restart, recreated it with the
server's default delivery policy — every message the stream holds — on a stream that keeps its history.
The build machine then took every ask since 1 October in order, and each outcome was taken in as news:
`takeIn` records the build and registers the manifest it carries, whatever commit it is from. Nothing
in the consumer derivation said where a seat's worker starts; nothing in the taking-in compared the
outcome's commit with what the catalogue already held.
Owner mesh-controller. Fixed in part: a worker re-made by the controller on a history-keeping stream
now delivers from the moment it is made (PR #233). Open for a decision, kept in the report's questions:
whether a seat's work queue should keep acknowledged asks at all, and whether an outcome for a commit
the catalogue has already moved past should be recorded but never registered or rolled out.
@@ -0,0 +1,42 @@
---
status: located
opened: 2026-10-03
located-in:
- mesh-controller
fixed-by:
amended-design:
---
# 208 — A seat's worker is made only when the controller starts, so a holder assigned later finds none
## What was observed
2026-10-03, assigning the first holders of `node-build-agent` ([ADR 0190](../../02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md))
to four machines and pushing them. Every agent came up, authenticated, bound its seat, and restarted:
```
taking build work as a holder of node-build-agent
mesh-builder: this machine cannot take work from node-build-agent: nats: consumer not found. The mesh
creates that queue and this machine's worker on it, and a build machine may not create one
```
The seat's stream existed; its worker did not. The controller makes a seat's worker where it raises
the bus's objects — on start — for the seats that have a holder at that moment, by design: *the queue
before the holder, so work queues until somebody arrives*. Nothing makes the worker when a holder
arrives later: a push asserts the module's own consumers (what it consumes) and not the seat's worker.
The remedy was a controller restart, so the raise ran again with the holder known.
## Why it matters beyond this instance
A seat's first holder is assigned after the controller started in every case but genesis, so every
new role's first holder meets this. The build machine met it on 2026-09-28 the same way ("left the
build machine bound to a consumer nothing had created") and the fix then was to pass the holders at
the raise — which fixed the start, not the arrival. The holder says the right thing and cannot do
anything about it, because a holder may not create its worker (design 25 §3).
## Diagnosis
Owner mesh-controller: the raise runs once (`RaiseSeats` with the holders of the moment); `push` and
`assign` run `EnsureConsumer` only for a module's declared consumption. **Fix direction:** when a
module claiming a seat with `accepts` is assigned, or on every push that composes a holder for such a
seat, ensure the seat's worker as the raise does — the same derivation, the same idempotent assertion.
@@ -0,0 +1,60 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-tools
fixed-by: mesh-tools #32
amended-design:
---
# 209 — A bundle's own copy of the SDK registers into a registry the runtime never reads
## What was observed
2026-10-03, preparing [design 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md)
WP4 — the first module whose tools bundle the node's runtime would load beside its own. Before the
manifest changed, a probe on the laptop did what the runtime does: a process holding one copy of
`@novox/mesh-sdk` imported a bundle in another directory that carried its own copy, and the bundle
called `registerModuleTools` as every tools entrypoint does.
```
registrations seen by host after importing bundle: 0
```
The import succeeds, the bundle registers, and the runtime's `collectTools` sees nothing. The
runtime would log the bundle as loaded and answer `tools` for the module with an empty list: silent,
and indistinguishable from a module that serves nothing by choice.
## Why it matters beyond this instance
WP3 found that *a TypeScript bundle must carry its dependencies*, and the toolchain copies them in
so a bundle starts anywhere. Among them is the SDK. The runtime imports a bundle in-process, and
the language resolves a bare import from the importing file's own tree — so every bundle brings a
second SDK into the process: its own registry of tools and its own broker handle. The SDK's registry
is a module-level list, and the runtime reads only the one it imported itself.
Every module from WP4 on is loaded this way. The runtime's tests never met it because their fixture
bundles sit under the runtime's own tree and resolve the same copy. Nothing in design 38 WP1 or WP3
says which SDK a loaded bundle speaks to, and the one-runtime record
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md))
assumes without saying that it is the runtime's.
## Diagnosis
Owner mesh-tools (`node-tools`): the runtime imports a bundle's entrypoint and counts the
registrations that appear after it, through the SDK it imported; the bundle's `import "@novox/mesh-sdk/tools"`
resolves to the copy under the bundle's own `node_modules`. **Fix direction:** the runtime resolves
every import of the SDK, from whichever bundle, as if the runtime had written it — one registry,
one broker — and leaves everything else a bundle carries to the bundle's own tree. A test loads a
bundle from a directory holding its own copy of the SDK and asserts its tools are served. The
toolchain keeps copying dependencies in: a bundle launched as a process ([ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md))
needs them, and an imported one is simply not allowed to bring a second SDK.
## Resolution
2026-10-03, mesh-tools #32: the runtime installs a synchronous resolve hook before the first bundle is
imported, sending every import of the SDK, from whichever bundle, to its own copy; a bundle's other
dependencies still resolve from its own tree, and a bundle launched as a process is untouched. The test
loads a bundle from a directory holding its own copy of the SDK and its own dependency, and sees its
tool served with the dependency's answer. Proven live 2026-10-03 by WP4's proof in design 38: the packet filter's bundle, the first loaded beside
the runtime's own, serves its four tools on all four machines.
@@ -0,0 +1,64 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-host
fixed-by: mesh-host #80
amended-design:
---
# 210 — The host re-creates the node's runtime on every reconcile, restarting it every ten minutes
## What was observed
2026-10-03, on the laptop, while proving [design 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md)
WP4. The host's log says the same thing at every reconcile, ten minutes apart, since the runtime
module first arrived at 00:04 — 82 times in the day's first thirteen hours:
```
created node-tools.runtime (node-tools): 239 file(s), running as node-tools.service
```
and systemd confirms it: `node-tools.service` is stopped and started at 12:39, 12:49, 12:59, 13:08.
Nothing else in those reconciles changed; every other resource is `kept`. The declaration is the
same one each time — no push happened between the cycles.
## Why it matters beyond this instance
The runtime is every module's tools on the node ([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
A restart every ten minutes drops every call in flight at that moment, re-subscribes every
membership, and re-imports every bundle; a bundle that is slow to load leaves the node's tools
silent for that long, ten minutes out of every ten. And the log reports it as success, so nothing
in `status` shows a node whose tools blink. The host's rule is that a resource reports `unchanged`
when the machine is as declared; a `process` here never does.
## Diagnosis
Owner mesh-host, `internal/apply/process.go`. The decision is: the digest of the fetched bundle
plus the unit text is `want`; when the host's record of what it last wrote equals `want` and no
`restart-on` resource changed, the process is left alone. Two things were ruled out on the laptop:
the credential the runtime restarts on, which has not been written since the evening before (the
host would also say `updated`, not `created`, for a restart it owed to another resource); and the
unit text, rendered with sorted keys and so stable. What fails is the record. The host's state file
holds an entry for `node-tools.runtime` — applied at the last cycle, with **no `wrote` digest at
all** — while the sibling entry for the runtime's credential carries its digest. The apply sets the
digest on its outcome on every path that installs the daemon, and the loop that records outcomes
copies it into the record for every kind; between the two, a `process` outcome arrives with its
digest empty. **Fix direction:** find where a `process` outcome loses its digest on the way to the
record, and a test that applies the same `process` declaration twice against a recorded store and
asserts the second outcome is `unchanged` with no restart — the test the shape never had. Observed
on the laptop's journal and state; the other three machines' host logs are not readable by the
operator account over SSH, and the behaviour is the host's, not the machine's.
## Resolution
2026-10-03, mesh-host #80. The diagnosis above was right about where and wrong about what: the
record is written every cycle, and the process applier's *unchanged* path returned an outcome that
said nothing about what was written, so the loop recorded it without the digest. The next cycle,
five minutes later, found an empty record and re-created the daemon; the one after was unchanged
and erased the digest again. `created` every other cycle is the ten-minute cadence, and it is why the
state file held the digest on one read and not on the next. The unchanged outcome now carries the
digest forward, as a file's and an archive's do. The test applies one process three times and
asserts the record survives an unchanged apply and no restart is asked; it fails on the code before.
Proven live on the laptop after the host rolled: two reconcile cycles with no `created
node-tools.runtime` line and the runtime's start time unmoved.
@@ -0,0 +1,52 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-controller
fixed-by:
- mesh-controller#246
amended-design:
---
# 211 — A bundle is built in the same tier as the toolchain it is compiled in, against the old toolchain
## What was observed
2026-10-03, merging a runtime change that needed a new SDK release. The SDK was published first;
then the merge of the tools repository planned two tiers, and the first held both the toolchain
images and the runtime's own bundle:
```
> tier 0
mesh-tools asked
node-tools built from 8e30ea9f
```
The bundle was built while its toolchain image was still being built. A TypeScript bundle's
dependencies are copied from the toolchain image it is compiled in
([design 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP3), so this one was
compiled and packed against the toolchain as it stood before the merge — carrying the old SDK —
and was recorded as built from the new commit.
## Why it matters beyond this instance
Every bundle compiled by a toolchain depends on the module that publishes that toolchain, and the
planner does not know it: a manifest names its toolchain by `language`, not in `build.on`, so the
dependency is implicit and the tiers are computed without it. Whenever a change touches the
toolchain and a bundle in one merge — or the toolchain's own repository holds a bundle, as this one
does — the bundle is built against the previous toolchain and reported current. Nothing fails; the
bundle simply carries yesterday's dependencies under today's commit.
## Diagnosis
Owner mesh-controller: the planner orders a merge's modules by `build.on`; the builder's toolchain
for a bundle (`ToolchainFor(language)`: the module and artifact it is compiled in) is not part of
that order. **Fix direction:** the planner treats a bundle's toolchain as a `build.on` it did not
have to write — a bundle of language L depends on the module that publishes L's toolchain — so the
bundle lands in the tier after it. A test: a merge touching the toolchain module and a TypeScript
bundle plans the bundle one tier later. Worked around on the day by building the bundle again once
the toolchain was built.
## Resolved
Every mesh-tools plan since the fix ran in two rounds: the toolchain and runtime images first, what is built in or on them after. Before it, a merge moving both tiered them together. The planner's test proves the order: a merge moving a bundle and its toolchain plans two rounds.
@@ -0,0 +1,11 @@
# 211 — Diagnosis
*2026-10-03.* The planner orders a merge's modules by `inventory.Dependencies`, whose edges come from a
manifest's `build.on`, from what a build recorded it stood on, from the repositories it read, and from
the build machine. A bundle names its toolchain by `language`; the builder takes the toolchain image
(`ToolchainFor(language)`) from what the mesh holds and records nothing of it as stood on. So no edge
ran from a bundle to the module publishing its toolchain, and a merge moving both (mesh-tools: the
images and node-tools) tiered them together. **Fix (mesh-controller, branch
`fix/issue-211-a-bundle-stands-on-its-toolchain`, commit c72f6ca):** `dependenciesOf` adds a `stands-on`
edge from every bundle artifact to its toolchain's module, read from the manifest. Tested: TypeScript
bundle → mesh-tools, Go bundle → mesh-tools-go, image → none; a merge moving both plans two tiers.
@@ -0,0 +1,49 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-tools
- mesh-controller
fixed-by:
- mesh-tools#44
amended-design:
---
# 212 — A toolchain rebuild keeps the SDK it cached, and every bundle built on it carries the old one
## What was observed
2026-10-03, rolling out [ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md).
SDK 0.1.5 was published, then the TypeScript toolchain image was rebuilt, then node-tools and six
bundles were rebuilt on it and pushed. On every machine the bundles carried SDK 0.1.3:
```
bundle SDK: "version": "0.1.3"
```
The launched packet filter and intrusion prevention, which register their seat first, then served
their seat's verbs as their own tools, and `node-packet-filter.*` and `node-intrusion-prevention.*`
stopped answering on three machines until fixed.
## Why it matters beyond this instance
The toolchain image installs its dependencies from a `package.json` that names the SDK by range.
The file did not change, so the image build reused its cached install layer, and "rebuilt after
the release" did not mean "carries the release". Every TypeScript bundle copies its dependencies
from that image ([design 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP3),
so an SDK release reaches no bundle until something else happens to change that file. Nothing
reports it: the build succeeds and records the new commit.
## Diagnosis
Owners mesh-tools (the image's recipe) and mesh-controller (the builder that runs it). **Worked
around** on the day by requiring `^0.1.5` in node-tools' `package.json` (mesh-tools #37), which
changes the layer. **Fix direction:** a toolchain build must not trust a cached dependency install —
install from a lockfile that a release updates, or build the install layer without the cache — and
the build should record which SDK version the image carries, so a bundle's record says what it was
compiled against. A check: after an SDK release and a toolchain rebuild, a bundle built on it reports
the released version.
## Resolved
The toolchain now installs the exact SDK version the mesh last published, passed in as a build argument from the SDK module's published package, and the planner orders the toolchain after the SDK. Proven 2026-10-04: the toolchain built with the published SDK, and the six TypeScript bundles built in it serve their tools and seat verbs.
@@ -0,0 +1,80 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-controller
- mesh-host
fixed-by:
- mesh-host#86
- mesh-controller#252
- mesh-controller#253
- mesh-host#88
amended-design:
---
# 213 — The controller is a Go program, and it still runs in a container
## What was observed
2026-10-03. The controller — the program that holds the `mesh-controller` seat and answers its
verbs (`status`, `nodes`, `push`, `assign` …), composes every machine's declaration and plans the
builds — runs on its machine as a container built from an image:
```
mesh-controller Up … (docker ps on the machine that runs it)
```
It is written in Go and compiles to one static binary, as the node host does. The host is delivered
as a bundle and run as a process; the node's tool runtime now is too
([ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)).
The controller is the one piece of the mesh's own Go code still shipped as an image.
## Why it matters beyond this instance
[ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
§1 says a module's own code is bundles, never an image, and §3 that a bundle that is a service is a
`process` the host runs. The controller breaks the rule it is the mechanism of: the registration
gate that will refuse an image of a module's own code has to exempt the controller, or refuse it.
It also costs what an image costs — a container runtime on its machine as a hard requirement,
eight mounts standing in for files a process would simply read, an image rebuild for a binary
change — and
every restart of it is a container recreation, which is how the controller restarts in the middle
of a plan today.
## What a fix has to settle
- The controller's module declares a Go bundle (`system`, `binary`) and a `process` running it
(`./<binary>`, mesh-host #81), with its credential and store connection as files and words, not
container mounts and a container network name.
- What the container gives it now that a process would not: it runs on the host's network already,
as an unprivileged user (65534), with eight mounts. Each mount named and replaced by a path, and
the user by an account the host declares.
- The handover: the controller restarting itself as a process, on the one machine that runs it,
without a window where nothing answers the mesh's verbs.
Located only by owner; the move is a change of the controller's module and its deployment, not of
its code.
## Fix prepared (2026-10-04)
Three changes. mesh-host#86, merged: a process may name the container it `replaces`, and the host
removes that container only once the process has stayed up across two checks. mesh-controller#252,
awaiting the operator's merge: the controller's composition for a service process, and two
controllers safe together for the handover — the second stands by on the controller's consumers
until the first lets go, and all plan work holds one advisory lock. mesh-controller#253, held: the
controller's manifest as a Go bundle and a process. It waits on
[issue 223](../223-a-new-mesh-installs-its-controller-as-a-container/00-report.md), because with it a
new mesh cannot be installed.
## Resolved
Proven 2026-10-04 on the control machine: after the manifest change was merged and pushed, the host
created the controller's account, ran its preparation step, started the controller as a process,
found it up across both checks and removed the container. The controller now runs as its own account
from its bundle, no controller container remains, its seat answered throughout, and the merge's own
plan finished all three tiers under the new process.
One fault on the way, fixed before it could leave two controllers or none: the host read the account
not existing yet as a user database that did not answer — it matched the exit as text in a wording
its own runner did not use — so the first apply stopped at the account and the container kept serving,
which is the handover's safe failure (mesh-host#88).
@@ -0,0 +1,41 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-controller
fixed-by:
- mesh-controller#246
amended-design:
---
# 214 — A plan loses track of the controller it rebuilds, and waits on it for ever
## What was observed
2026-10-03, a merge to the controller's repository planned three tiers: the controller itself, then
the build agent, then the route proxy. The controller's build finished and was recorded at 19:24:
```
mesh-controller built 2ebbb799 g14 2026-10-03 19:24
```
Twenty-seven minutes later the plan still read `tier 1 of 3 … mesh-controller asked`, and every later
plan waited behind it. It was stopped by hand; the later tiers were never asked.
## Why it matters beyond this instance
The controller rebuilding itself is the one plan whose first tier replaces the process that runs the
plan. The new controller starts with the plan's state as stored, and the outcome of the build that
produced it arrived to the old one, or between the two. Every merge to the controller's own repository
can end this way, and each one blocks every plan after it until somebody notices.
## Diagnosis
Owner mesh-controller (the planner). **Fix direction:** on start, and whenever a plan waits on a
build, the plan settles an `asked` build against the build records — a build recorded as built from
the plan's commit is that tier's outcome — so a plan resumes after the controller replaced itself.
A test: a plan whose build outcome was recorded while no controller followed it resumes on start.
## Resolved
A plan settles an asked build from the build records, whoever heard the outcome. Proven 2026-10-03 and 2026-10-04: both controller merge plans since the fix finished all three rounds, including the round that replaced the controller, without being stopped by hand.
@@ -0,0 +1,10 @@
# 214 — Diagnosis
*2026-10-03.* A plan learns a tier's outcome only through `planBuilt`, called when a controller takes
in a build result off the bus. A merge to the controller's repository replaces the controller in tier
0; the build that produced the new controller was recorded, but the plan state the new controller
loaded still read `asked`, and no path ever revisited it. **Fix (branch
`fix/issue-214-a-plan-settles-from-the-build-records`, commit d86baeb):** `advanceOnce` settles every
still-asked module from its build records — a build recorded after the ask is that ask's outcome,
built or failed — on every advance and on the 30-second ticker. Tested with a pure helper. Live
proof: the next merge to mesh-controller passes tier 0 on its own.
@@ -0,0 +1,42 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-controller
fixed-by:
- mesh-controller#246
amended-design:
---
# 215 — A module built once at a commit stops following its branch, and merges leave it out
## What was observed
2026-10-03, a catalogue merge changed seven modules. Its plan built six; `unifi` was not in it,
though its change was in the same commit. A second merge the same evening left it out again. Its
build records name a commit where every other module names a branch:
```
unifi built 9c97a8a1 … http://…/mesh-catalog.git at 9c97a8a
```
Built by hand from `main` it was registered again and the change reached its machine.
## Why it matters beyond this instance
A module that was once built at a commit — to pin it during a fix, or by a build asked with a `ref`
— silently stops following its branch: merges plan without it, `status` does not say it is behind its
branch, and nothing says it is pinned. The operator learns it when a change does not arrive.
## Diagnosis
Owner mesh-controller. **Fix direction:** a build asked at a commit does not change the branch a
module follows; or, if pinning is meant, the pin is said — in `module list`, in `status`, and by a
merge's plan naming the module it leaves out and why. A test: building a module at a commit and then
merging a change to it plans it.
## Resolved
Proven 2026-10-04: the catalogue merges since the fix rebuilt the module that had been pinned at an
old commit, at the merge's commit, by an ordinary plan — the same as every other module of the
catalogue. Nothing was asked for it by hand.
@@ -0,0 +1,10 @@
# 215 — Diagnosis
*2026-10-03.* `takeIn` registers a build's `Ref` as the branch the module follows. unifi was once
built with `ref=9c97a8a`, which became its followed ref. `sourceIs` matches a merge only to modules
whose ref is empty or the merged base — so every merge into main left unifi out — and `askTier`
re-asks `Source.Ref`, so every plan that rebuilt unifi built the same old commit again (its build
records all read "at 9c97a8a"). **Fix (branch `fix/issue-215-a-commit-is-never-a-branch-to-follow`,
commit 6784efa):** registration keeps the followed branch when a build names a commit; matching and
re-asking read a recorded commit as the default branch, healing existing records; a merge names the
modules of its repository it leaves out. Store-backed test fails without the fix.
@@ -0,0 +1,35 @@
---
status: resolved
opened: 2026-10-03
located-in:
- mesh-controller
fixed-by:
- mesh-controller#246
amended-design:
---
# 216 — A tools bundle nothing says to load is built, recorded, and never delivered
## What was observed
2026-10-03, seven modules moved their tools from a container to a bundle. Each built, each build was
recorded, and the machines that run them reported current — with none of the bundles on them. The
composer delivers a bundle only when the runtime loads something from it, and that is derived from
the module's `tools` list when the artifact names no `loads`; these modules had neither.
## Why it matters beyond this instance
Every step reported success: the build, the registration, the push, the machine's apply. The tools
were simply absent, and the old container was gone. A rule the composer applies silently is a rule
nobody learns until the tools are missing.
## Diagnosis
Owner mesh-controller (the catalogue's registration check). **Fix direction:** a bundle that nothing
loads, runs or unpacks — no `loads`, no `tools` list on its module, no resource naming it — is refused
at registration, naming the field that would deliver it. A test: such a manifest is refused; adding
`loads` admits it.
## Resolved
A bundle nothing would deliver is refused at registration, by name. Proven by the controller's tests; the catalogue's bundles all name what the runtime loads (mesh-catalog#243).

Some files were not shown because too many files have changed in this diff Show More