Author SHA1 Message Date
jochen d444458bf6 Issue 195: resolved by the controller composing users only for modules that can read an account 2026-10-04 17:37:20 +02:00
mesh-admin dd8b15a0df Merge pull request 'Issue 195: located in the controller's user composition' (#370) from issue/195-diagnosis into main 2026-10-04 15:34:11 +00:00
mesh-admin 02b4ca9bec Merge pull request 'ADR 0213: the operator sets the agent's managed settings through the agent module' (#369) from feat/claude-code-agent-settings into main 2026-10-04 15:34:09 +00:00
jochen 7cd5b37afe Issue 195: located in the controller's user composition
Records why a module that cannot read an account is no bus user, what else
read those users (durable consumers), and answers the report's questions.
2026-10-04 17:33:27 +02:00
jschoubben aac0da9c0c Merge pull request 'ADR 0199: a module that answers names declares its zone, and a node's hosts file is one module's' (#337) from decision/0199-zones-and-the-hosts-file into main 2026-10-04 15:33:07 +00:00
jochen 57b818b669 ADR 0213: the operator sets the agent's managed settings through the agent module
Rules for what the agent may do were set by hand per machine, invisible to
the mesh, and a session cannot loosen its own permissions; design 36 now
takes them as a module setting under the mesh's own keys.
2026-10-04 17:32:18 +02:00
mesh-admin e5042e1e7a Merge pull request 'Issues 237, 238: a self-contradicting assign answer, and the operator's address banned' (#367) from issues/237-238-a-stale-answer-and-a-banned-operator into main 2026-10-04 15:06:51 +00:00
jochen 2580fb6839 Issues 237, 238: a self-contradicting assign answer, and the operator's address banned 2026-10-04 17:06:38 +02:00
mesh-admin 4a8a378ef9 Merge pull request 'ADR 0212: a seat says what it receives, and the machine's hotkeys are a seat' (#366) from decision/0212-contributions-to-a-seat-and-hotkeys into main 2026-10-04 14:42:31 +00:00
jochen 3f48e685cc ADR 0212: a seat says what it receives, and the machine's hotkeys are a seat 2026-10-04 16:42:21 +02:00
mesh-admin 1e0b9ee11f Merge pull request 'As-is: a module's state, and the agent with its licences; designs 36, 39, 40 implemented' (#365) from design/state-and-licences-as-is into main 2026-10-04 14:33:50 +00:00
jochen 1faa63b2d5 As-is: a module's state and the agent with its licences; designs 36, 39 and 40 implemented
What runs since 2026-10-04 and what its first live use showed: refused
requests as timeouts, a late machine reading the whole set, the partial
secrets guard; the agent module and the licence manager on every machine.
2026-10-04 16:32:29 +02:00
mesh-admin 3a359bf99e Merge pull request 'ADR 0211: a machine's power is a node seat, its moments take contributions, and its states are events' (#364) from decision/0211-power-is-a-node-seat into main 2026-10-04 13:58:25 +00:00
jochen ec6d106af8 ADR 0211: a machine's power is a node seat, its moments take contributions, and its states are events 2026-10-04 15:58:16 +02:00
jochen e2b4f3a5c4 Regenerate the decision index 2026-10-04 15:58:09 +02:00
mesh-admin dfb2817fe7 Merge pull request 'ADR 0210: a tool's configuration is its seat holder's, and every other module extends it through the seat' (#361) from decision/0210-a-contribution-depends-on-the-seat-that-receives-it into main 2026-10-04 13:42:56 +00:00
mesh-admin 12742e3a3f Merge pull request 'Designs 36 and 39: the manager's verb is public-key' (#363) from fix/the-verb-is-public-key into main 2026-10-04 13:41:32 +00:00
jochen 5b00bdc7af Designs 36 and 39: the manager's verb is public-key (a seat's verb takes no underscore) 2026-10-04 15:41:23 +02:00
mesh-admin d5a96e5c63 Merge pull request 'Research 028: the mesh's output channel' (#362) from research/028-the-meshs-output-channel into main 2026-10-04 13:36:17 +00:00
jochen 73e6400802 Research 028: the mesh's output channel — how the mesh tells its operator what it noticed 2026-10-04 15:36:03 +02:00
jochen f144ad7be4 To-be 42: who writes what in phase 2 (ADR 0210) 2026-10-04 15:20:01 +02:00
jochen 8394a7bfb1 ADR 0210: a tool's configuration is its seat holder's, and every other module extends it through the seat 2026-10-04 15:19:44 +02:00
mesh-admin 6ac936f170 Merge pull request 'Issues 235, 236: an assignment and a check that let through what then fails' (#360) from issues/235-236-an-assignment-and-a-check-that-let-through-what-fails into main 2026-10-04 13:19:41 +00:00
jochen 3af4179755 Issues 235, 236: an assignment and a check that let through what then fails 2026-10-04 15:17:46 +02:00
mesh-admin dd04729ec5 Merge pull request 'ADR 0209: a login on a node moves that node to its account; an API key is added from any node, sealed' (#359) from decision/0209-a-login-moves-its-node into main 2026-10-04 13:12:01 +00:00
jochen 2e5f40c9fa ADR 0209: a login on a node moves that node to its account; an API key is added from any node, sealed
Traced live: a login to a second account was adopted and left its node
bound to the first, holding a spent refresh token. Designs 36 and 39
amended.
2026-10-04 15:09:56 +02:00
mesh-admin 2f41b4124d Merge pull request 'Issues 233, 234: a stale declaration removed four modules, and the host could not recover' (#358) from issues/233-234-a-stale-declaration-and-a-host-that-cannot-recover into main 2026-10-04 13:09:47 +00:00
jochen 9cc00d57ea Issues 233, 234: a stale declaration removed four modules, and the host could not recover 2026-10-04 14:54:50 +02:00
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 3ed55a3420 connectivity: name the code that builds the one resolver, zones and the hosts file 2026-10-04 00:23:43 +02:00
jschoubben 8184585213 ADR 0199: a module that answers names declares its zone, and a node's hosts file is one module's
The per-node resolvers 0194 retires held two kinds of names that are neither nodes nor routes: the
lab's scenario machines and an operator's own lines. A zone a module declares is forwarded by the
mesh's resolver to that module; /etc/hosts is held per node through node-hosts-file, the operator's
lines in its kept region. Research 023 parks seats that define what their holder owns.
2026-10-03 23:19:36 +02:00
31 changed files with 1585 additions and 18 deletions
@@ -0,0 +1,37 @@
---
status: active
initiated: 2026-10-03
touches: [the seats, the seat protocol, the controller's ownership check, 03-DESIGN/01-to-be/26-the-seats.md]
---
# 023 — A seat protocol that defines what its holder owns
## What is investigated
**A seat is a definition — a protocol — and a module occupies it by implementing that protocol.**
Today the protocol is what the holder accepts, emits and serves (ADR 0118, 0129, 0132): its verbs, as MCP
tool definitions. This asks whether the protocol should also name the **files and directories the
holder owns**, so that occupying the seat means owning them: `node-resolver-config` owns
`/etc/resolv.conf`, `node-hosts-file` owns `/etc/hosts`, the intrusion prevention owns its jail file.
The direction is the protocol's, not the holder's: the seat states what any holder must own; a module
that wants the seat must declare those paths among its resources, or the controller refuses the claim
as not implementing the seat. Two seats may not name one path.
## Why
Who owns a singular file is today answered by reading every manifest, and enforced only after the fact,
when two modules on one machine both declare the same path. The question *which module owns
`/etc/resolv.conf`?* came up on 2026-10-03 with no place to look it up. A seat that names the path answers
it from the seat table, before any module is written, and makes "implements the seat" checkable.
## What it touches
- The seat definition and its table (ADR 0122) — a new part of the protocol.
- The controller's ownership check (`checkResources`), which already refuses two modules owning one path.
- Every node seat that is really about a file: `node-resolver-config`, `node-hosts-file`
([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)),
`node-intrusion-prevention`, `node-packet-filter`.
Raised by the operator during the resolver work of ADRs 0194–0199 and parked there so that work was not
widened by it.
@@ -0,0 +1,71 @@
---
status: active
initiated: 2026-10-04
touches:
- 04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md
- 04-ISSUES/229-a-rollout-cannot-be-followed-through-the-meshs-tools/00-report.md
- 04-ISSUES/230-a-host-that-hands-over-to-a-newer-one-loses-its-report-and-a-plan-waits-for-ever/00-report.md
- 04-ISSUES/233-a-host-without-its-package-managers-configuration-refuses-the-declaration-that-would-restore-it/00-report.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
- 03-DESIGN/01-to-be/32-what-a-module-declares.md
became: []
---
# 028 — The mesh's output channel
## What
How the mesh tells its operator what it noticed. The operator's framing: sending notifications
is **an output channel for the mesh**. The mesh already knows when a machine stops answering, when
a failure repeats and will not fix itself, when a rollout waits for ever. Today it keeps that to
itself until someone asks.
The effort looks at:
- **the seat:** one, held once for the mesh, that every other part uses to say something to the
operator;
- **the channels**, each a module: a desktop notification on the machine the operator is at,
**Telegram**, a phone push service, chat, mail and others (see [02](02-the-channels.md));
- **the routing**, by severity and by where the operator is;
- **the life of a message:** deduplicated while it holds, resolved when it stops, acknowledged or
silenced by the operator;
- **the watcher's watcher:** who tells the operator when the parts that would tell them are the
ones that failed.
## Why
[Issue 187](../../04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md) is the
class: *the mesh tells nobody when it stops working*. [01](01-what-the-mesh-already-knows.md)
counts it.
- 15 of the 236 issue reports say the fault was found because a person happened to look.
- 74 describe something failing silently.
On the day this effort opened, the mesh knew three things and told nobody:
- a workstation had refused every declaration for ninety minutes;
- the same workstation had been out of touch for ten minutes after an upgrade;
- one failure on the laptop had repeated thirteen times.
Every one of them was in `status`, for whoever asked.
The pieces exist. [To-be 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) already uses a
`telegram-sender` seat as its worked example of a work queue with retention. ADR 0208 made a
machine's desktop notifier a node seat with a `send` verb. What is missing is a seat that speaks
for the mesh, sources that call it, and channels that deliver.
## What it touches
- **The controller**, which would become the first source of what it already computes for `status`.
- **The node-notifier seat**, which would become one channel among several.
- **Issues 187, 229, 230 and 233**, each of which ends in "and nothing said so".
- **ADR 0210**, because a channel extends the output seat through a contribution, and therefore
depends on it.
## Documents
1. [What the mesh already knows](01-what-the-mesh-already-knows.md): the evidence, and the events
that exist.
2. [The channels](02-the-channels.md): the candidates, Telegram first, weighed on the same questions.
3. [Open questions](03-open-questions.md): the seat, routing, life of a message, the watcher's
watcher, what may leave the mesh.
@@ -0,0 +1,60 @@
# 01 — What the mesh already knows, and who hears it
## The count
Over the 236 issue reports in `04-ISSUES/`, on the day this effort opened:
- **15** say, in some wording, that a person found the fault by looking: "nobody was told",
"nothing logged / said / alerted / emitted", "a person asked", "found by a person". Five of them
are still open.
- **74** describe something that failed silently.
The search was a word match over the reports' text, so it undercounts reports that tell the same
story in other words. It never overcounts by much: each of the fifteen was read.
The fifteen fall into three groups:
- **The mesh knew, and kept it in a query.** The fault was in `status`, `plans` or a node's record,
for whoever asked. Examples: a rollout waiting for ever (230), a machine refusing every declaration
(233), a setting that cannot work stored and stopping the node (096).
- **The fault was in a log nothing reads.** Examples: the bus refusing the controller's publishes
(187), a dropped report (187, 230).
- **The fault was invisible to the mesh itself.** Examples: a resolver outside the mesh closed by its
filter (198), a port narrowed without saying (086).
Only the first group is a matter of telling: the fact exists, and only delivery is missing. The
other two need a source first. This effort is about the first, and about giving the other two a
place to say something once they can.
## What the controller computes and does not say
Read from `status` and `node show` on the day this effort opened. Each line is a fact the controller
already holds:
| fact | where it is today | example that day |
|---|---|---|
| a machine is out of touch | `node show`: "last heard from — out of touch 10m" | a workstation after an upgrade |
| a machine refused its declaration | `status`: "refused" with the reason | the same workstation, for 90 minutes |
| a failure repeats and will not fix itself | `status`: "stuck: the same failure N times since …" | 13 times on the laptop |
| machines run different hosts | `status`: the version table | after a host release |
| something runs that the mesh did not write | `node show`: strays | 16 containers on one machine |
| a filter rule the mesh did not write | `status` | one machine |
| a plan is waiting | `plans` | issue 230: "for 0s", for ever |
| an assignment does not compose | the `assign` answer only | issue 235 |
None of these is published. The bus carries a seat's own events (a build's outcome), a module's
declared events, tool calls and declarations. It carries no event for any line above.
## What exists to deliver with
- **A machine's desktop:** the `node-notifier` seat (ADR 0208), held on the laptop. Its `send` verb
shows a notification, and `history` lists them. It was used through the console the day this effort
opened.
- **Mail:** a mail module provides `smtp` to the mesh.
- **Chat:** a Matrix server runs as a module on the home server.
- **Home automation:** a home-automation module runs there too, and its phone app can receive pushes.
- **A seat shape for exactly this:** to-be 32 §5 uses `telegram-sender` (`accepts: send`,
`retain 7d`, `emits: delivered, failed`, `serves: status`) as its worked example. A seat's stream
exists from registration, so work queues until a holder appears.
No module sends to Telegram, a phone push service or SMS today.
@@ -0,0 +1,116 @@
# 02 — The channels
Each channel is a candidate module that delivers what the output seat hands it. They are weighed on
the same questions:
- **Reach:** does it reach the operator away from the machines (phone), or only at a desk?
- **Off-mesh:** does it still work when the mesh's own parts (the bus, the controller, the control
node's network) are what failed?
- **Two-way:** can the operator answer through it: acknowledge, silence, ask?
- **Where the words go:** does the message leave the operator's own machines, and to whom?
- **What it costs to hold:** a secret, a server, an account, money.
## The candidates
### Telegram (required by the operator)
A bot created with Telegram's bot service sends to one chat: the operator's own, or a group.
- **Reach:** the phone and every desktop, with push.
- **Off-mesh:** sending needs only outbound HTTPS from any machine. No inbound port, no server of the
mesh's own. A second machine can hold the same bot token and send when the first is the one that
failed.
- **Two-way:** yes. Inline buttons on a message (acknowledge, silence for an hour) and commands to
the bot, read by long polling over outbound HTTPS. This makes Telegram the strongest candidate for
answering, and the riskiest (see [03](03-open-questions.md), Q7).
- **Where the words go:** to Telegram's servers. Bot chats are not end-to-end encrypted. What a
message may contain is therefore a rule this effort must set.
- **Cost:** one secret (the bot token) and the chat's id. Free. Rate limits are far above what an
operator should receive.
- **Formatting:** short text with a little markup, buttons and links. Enough for a subject, a
machine role, a severity and one line of why.
### The desktop notifier (exists)
The `node-notifier` seat's `send` verb on the machine the operator is at.
- **Reach:** only at that machine, only while a session is up.
- **Off-mesh:** no. It is reached through the mesh's tools.
- **Two-way:** dunst has actions, which a click can answer, but nothing reads them back yet.
- **Where the words go:** nowhere; it is local.
- **Cost:** none.
- **Its place:** the gentlest channel, for a warning while the operator is at a desk. "At a desk" is
itself a question: an unlocked session on a machine with recent input.
### ntfy (or Gotify): a self-hosted phone push
A small server publishes topics; its phone app subscribes.
- **Reach:** the phone, with push.
- **Off-mesh:** only if the server runs outside what failed. On the control node it fails with it.
- **Two-way:** action buttons can call a URL, which is an inbound path to design.
- **Where the words go:** stays on the operator's machines when self-hosted. ntfy's iOS push passes
through an upstream relay unless configured otherwise.
- **Cost:** a module with a container and a routed name; a token per topic.
### Matrix (a server exists as a module)
A bot account posts to a room the operator is in.
- **Reach:** phone and desktop through any Matrix client.
- **Off-mesh:** no, the server is one of the mesh's modules.
- **Two-way:** yes, by messages to the bot.
- **Where the words go:** stays on the operator's server, end-to-end encrypted if the bot supports it.
- **Cost:** a bot account, a secret.
### Mail (a mail module provides `smtp`)
- **Reach:** everywhere, without urgency.
- **Off-mesh:** no, if the mesh's own mail server sends. Yes, through an outside relay.
- **Two-way:** no, not usefully.
- **Its place:** the record and the digest: a daily summary of what was said and resolved, and the
fallback when nothing else acknowledged.
### The home-automation companion app (a module exists)
Its phone app takes pushes and actionable notifications, and the home has lights and speakers.
- **Reach:** the phone, and the house itself: a light that turns a colour.
- **Off-mesh:** no, the home server is a node.
- **Its place:** a playful critical channel, not a primary one.
### The bar on the desktop
An `i3status-rust` block showing the count of open messages, red while one is critical.
- **Reach:** the desk only, and silent.
- **Its place:** the ambient state. Nothing interrupts the operator, and they always see whether
something is open.
### The console (an agent session)
A message the next agent session opens with ("two things happened while you were away").
- **Its place:** context for the agent working on the mesh rather than an alert. It falls out of the
message store if the store is queryable.
### Others, noted and not pursued now
- **SMS or a voice call** through a paid gateway. It is the only channel that works with no data
connection, and the only one that costs per message.
- **Signal**, through an unofficial client: no bot API, and a registered number.
- **Discord or Slack** webhooks: the words go to a third party, as with Telegram, without its two-way
strength.
- **Pushover:** paid, closed, and a phone push service much like ntfy.
- **An external dead-man service** (a heartbeat URL that alerts when pings stop). It belongs to
[03](03-open-questions.md), Q6, as the watcher's watcher rather than as a channel.
## A first reading
- **Telegram** is the primary phone channel, and the only candidate that is cheap, off-mesh capable
and two-way at once.
- **The desktop notifier** is for the desk.
- **The bar** shows the ambient state.
- **Mail** carries the digest and the record.
- **ntfy and Matrix** are self-hosted alternatives for an operator who keeps words off third parties.
The seat must make that a choice, not a rewrite.
@@ -0,0 +1,127 @@
# 03 — Open questions
Each question names the options seen so far. None is decided here.
## Q1. The seat
**What speaks for the mesh to its operator?**
- **a.** One seat in the mesh's own set, held once for the mesh. Working name: `operator-channel`.
- It **accepts** `notify` (a work queue, as to-be 32 §5 designs `telegram-sender`), so a message
waits until a holder appears.
- It **emits** `delivered`, `acknowledged` and `resolved`.
- It **serves** `open` (what is unresolved now) and `history`.
- **b.** No seat: every source calls every channel. Rejected in advance, because each source would
learn every channel. This is the inversion ADR 0126 exists to prevent.
- **c.** Each channel as its own seat, with routing in the sources. Same objection as b, one level up.
Under a, the holder routes. The channels are modules that **contribute** themselves to the seat
(ADR 0210): a channel extends the seat, and so depends on it. Whether the holder is a module of its
own or part of the controller is open. A module keeps the controller small. The controller already
holds most of the facts.
## Q2. What a message is
The first shape seen: a **subject** (what it is about: a machine's role, a module, a plan), a
**kind** (out of touch, refused, stuck, late, …), a **severity**, a one-line **why**, a link to
the tool that shows more, and a **key** that makes it the same message the next time it is said.
- **Severity:** two levels (needs you now / when you can), or three (critical / warning / info)?
Every extra level is a routing rule somebody must keep right.
- **The key** is what makes deduplication possible. "Machine X out of touch" said every minute is
one message, still open, not sixty.
## Q3. The life of a message
open → (acknowledged) → resolved.
- **Deduplicate** by key while open.
- **Resolve** when the source stops saying it, or says it is over ("back in touch after 14 min"). A
channel that can edit its message (Telegram can) updates it in place rather than sending a second.
- **Acknowledge** from any channel that can answer, which stops escalation and repeats.
- **Repeat or escalate** an unacknowledged critical message after a while, to the next channel.
- **Where the open set lives:** the seat's own state, in a key-value bucket (to-be 32's `state:`), so
`open` answers after a restart.
## Q4. Routing and presence
- **By severity:** critical goes to every channel at once. A warning goes to the desk when the
operator is at one, otherwise to the phone, otherwise to the digest.
- **Presence:** "at a desk" needs a fact the mesh does not hold yet. Candidates: an unlocked
graphical session with recent input, read from the `node-lock-screen` and `node-login-manager`
seats' holders. Nothing more invasive.
- **Quiet hours:** a setting of the seat's holder (ADR 0174). Critical overrides it, or not, as the
operator chooses.
- **Rate:** a cap per hour per channel, with the excess folded into one summary, so a storm (a
network outage where every node is out of touch) arrives as one message naming many.
## Q5. The sources
The first sources are the facts in [01](01-what-the-mesh-already-knows.md), all in the controller
today:
- a machine out of touch;
- a declaration refused;
- a stuck failure;
- a plan late (once issue 230 gives a wait an age);
- an assignment that does not compose (issue 235);
- a host version split.
**How each becomes an event:**
- **a.** The controller emits an event per change of state, and the seat's holder consumes them.
- **b.** The controller calls `notify` itself.
With a, the controller learns nothing about telling: other consumers (a board, a log) get the same
facts, and the holder decides what is worth a message. With b, the controller decides severity.
**Modules as sources:** a module may `use` the seat to tell the operator something of its own
(a backup failed, a certificate is close to expiry), with the same message shape.
## Q6. The watcher's watcher
When the controller, the bus or the control node is what failed, nothing above runs. Options:
- **A dead-man signal:** the seat's holder sends a heartbeat out of the mesh (a ping to an external
heartbeat service), which alerts the operator by its own means when pings stop.
- **A second holder of the Telegram channel on another machine** that sends directly, without the
bus, when it stops hearing the controller for longer than a bound.
- **Each host** sending a last message itself when it loses the mesh for longer than a bound. This
needs the channel's secret on every machine, a cost to weigh.
The first is the cheapest and the only one that also covers "the whole house is offline".
## Q7. Answering back
Telegram, and Matrix, can carry the operator's answers.
- **Acknowledge and silence** are safe: they change only the message's state.
- **Commands** ("push the workstation", "show status") turn a chat account into a door to the
controller. If it ever comes, it needs:
- its own record;
- a narrow verb set;
- a check that the answer came from the operator's own account and chat;
- and probably a confirmation step.
The first version should probably answer with acknowledge and silence only.
## Q8. What may leave the mesh
Telegram, and any third-party channel, carries the words to someone else's servers. A message
names a machine, a module and a reason, which is operational detail.
- **What may a message contain?** Roles rather than addresses; no secrets, tokens or paths; a reason
in words. The rule must be enforced by the seat's holder, not hoped for from each source.
- **Is a self-hosted channel required for anything above a severity?**
- **The bot token and chat id** are secrets of the channel's module, delivered as any module secret is.
## Q9. How it is checked
A rule this effort produces must say how it is verified. Candidates:
- a message said twice with one key is one message;
- a resolved source resolves its message;
- a critical message reaches every channel within a bound;
- a message containing an address or a secret is refused;
- the dead-man signal fires when the holder is stopped.
Each is a test of the holder, or a live drill: stop a machine's host and time the message.
@@ -0,0 +1,127 @@
---
topic: the tiers
status: accepted
date: 2026-10-03
deciders: jochen
reconstructed: false
extends: 0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
---
# 199. A module that answers names declares its zone, and a node's hosts file is one module's
## Context
**[ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md) and
[ADR 0196](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md) leave
one resolver holding the nodes' internal domains, and retire the resolver every node ran.** Two kinds
of names lived in those per-node resolvers that are neither a node nor a route, and both were found on
the workstation on 2026-10-03:
- **Names a module answers.** The lab raises scenario machines and gives them addresses from its
scenario files — the anchor's stand-in at a documentation address, the home server's on the LAN —
and the workstation resolved `<machine>.incus` through two wildcard lines in a drop-in file its
resolver read. The lines were written by hand; the addresses are the lab's, known only while a
scenario runs.
- **The operator's own names, unrelated to the mesh.** Twelve `<loopback> <name>` lines for a
client's development hosts, kept in `/etc/hosts` and again in `/etc/hosts.local`, which the per-node
resolver read as additional hosts.
**A manifest never names an address, a node or a domain** ([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)).
So the lab cannot list `<machine>.incus → <address>` in its definition, and the operator's twelve lines
are not any module's to define.
## Considered Options
**For a module's names:**
1. **The manifest lists its records.** Refused by ADR 0112: the addresses are the lab's runtime facts
and the scenario's choice.
2. **The module reports its records at runtime to the mesh's resolver**, which writes them into its
configuration. It works, and it makes the resolver hold every module's runtime state and decide,
per call, whether the caller may write the name it sent — authorisation for a write, on the one
server every node depends on.
3. **The module declares the zone it answers and the listen that answers it; the mesh's resolver
forwards that zone there.** The definition names a zone (from a setting) and one of its own listens,
which ADR 0112 allows; the address and the port are the mesh's facts. The records stay where they
are known — in the module, at runtime. Chosen.
**For the operator's names:**
1. **Records the controller holds, served by the mesh's resolver.** They are not the mesh's: a client's
development hosts on one machine are nothing any other node should resolve, and the controller would
become the keeper of a workstation's private notes.
2. **A node-scoped module owns `/etc/hosts`, and the operator's lines live in its kept region**
([ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)), changed
through that module's tools on that machine. Chosen.
## Decision
**1. A module that answers names declares a zone.** Its definition names the zone — a single label or a
dotted name, from a setting, never a domain the mesh knows — and the listen that answers DNS for it.
The controller refuses two modules in the mesh declaring one zone, and a zone that is the mesh's suffix,
under it, or one of a node's public domains: a module may not shadow names the mesh or the public DNS
answers.
**2. The mesh's resolver forwards each zone to the module that declared it.** The controller hands the
holder of `mesh-dns-resolver` every declared zone with the private address of the node its module runs
on and the port that listen is published on; the holder places one forwarding rule per zone into its
configuration and answers nothing in that zone itself. What names exist in the zone, and their
addresses, are the module's — answered by its own long-running code
([ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)),
from its own state, as they change. Whether an answered address is reachable from the asking node is
the module's matter, not the resolver's.
**3. A node's `/etc/hosts` is held by one module, through a node seat, `node-hosts-file`.** The seat is
the definition: its holder owns `/etc/hosts`, and implements three verbs — MCP tool definitions served
as `<node>/node-hosts-file.<verb>` ([ADR 0195](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)):
**`entries`** (the file's lines, the module's and the operator's, each marked whose), **`add`** (one
address and its names, into the operator's region) and **`remove`** (one name or address from it). The
module writes the machine's own lines — loopback and the machine's name — and keeps a region for the
operator, which survives every push and is given back when the module goes. Its tools change that
region on that machine, escalating as the packet filter's do
([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) §4). **The
controller holds none of it:** an operator's line is the machine's, not a record.
**4. No other module writes `/etc/hosts`.** The private network's region goes, as ADR 0194 already has
it; a module that once wrote a line there asks the mesh's resolver instead.
## Consequences
- **The lab's names follow its scenarios.** A scenario raised is resolvable from every node at once; a
scenario torn down is gone, with no line left behind in any file.
- **The mesh's resolver holds no module's state.** It holds the nodes' domains and a table of who
answers which zone, both composed by the controller; nothing writes to it at runtime.
- **A module answering a zone needs a DNS answerer of its own** — a long-running bundle, or a resolver
it runs. The lab gains one.
- **The operator's names reach the machine's own programs, not its containers.** A container does not
read the machine's `/etc/hosts`. For names unrelated to the mesh that is the right boundary; a name a
container needs belongs in a zone.
- **Taking `/etc/hosts` keeps what is there.** The first time the module writes the file, every line
that is not the machine's own goes into the operator's region, so a workstation's twelve lines survive
the take — the same adoption [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md) gives
every shared file.
**How each is checked:**
- **Zones:** the controller's catalogue tests refuse a second module declaring a zone, a zone under the
mesh suffix, and a zone equal to a node's public domain.
- **Forwarding:** on the holder, the resolver's configuration carries one forwarding rule per declared
zone, at the declaring node's private address and published port; asking any node's resolver for a
name in the lab's zone while a scenario runs returns the scenario's address.
- **The hosts file:** a push leaves the operator's region byte for byte; `add` followed by `entries`
shows the line as the operator's; unassigning the module gives the region back.
## References
- [ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md),
[ADR 0196](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md) —
the one resolver and how nodes ask it.
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why a definition names no address.
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
[ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md) — kept regions and shared files.
- [ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md) —
where a zone's answerer runs.
- [Connectivity §2](../03-DESIGN/01-to-be/08-connectivity.md) and [the seats](../03-DESIGN/01-to-be/26-the-seats.md),
amended alongside.
- [Research 023](../01-RESEARCH/023-a-seat-protocol-that-defines-what-its-holder-owns/00-overview.md) —
the general form of decision 3's "the holder owns `/etc/hosts`".
@@ -93,6 +93,11 @@ A node with contributions and no holder writes them nowhere. The holder's absenc
node's assignments, and no contributor is refused for it, because a missing `PATH` entry is a gap, not
a broken machine.
> **The mechanism changed — 2026-10-04, by [ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md).** A contribution is now a dependency on
> node-environment, met and refused as ADR 0207 says, so a node without the holder refuses the
> contributor instead of writing the contribution nowhere. The rest of this section, and the
> decision, stand.
## Consequences
- A shell's part in the environment is one line in its always-read startup file, sourcing the
@@ -87,6 +87,12 @@ reports, `graphical-session`, still gates the display server itself.
The holder of `node-display-server` places them with `${shell:xinitrc:<slot>}` and
`${shell:xresources:<slot>}`.
> **The mechanism changed — 2026-10-04, by [ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md).** A contributor no longer places its own
> file in another tool's directory. It contributes to the tool's seat, and the seat's holder places it,
> in that directory or through a placeholder. Every contribution, the `xinitrc` and `xresources` slots
> included, is a dependency on the seat that receives it. What a contribution contains still follows
> the tool's grain.
**5. The display server's module writes the session's start.** It writes a block at the start of
`~/.xinitrc`, in this order:
@@ -0,0 +1,80 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
---
# 209. A login on a node moves that node to the account it logged in to; an API key is added from any node, sealed
## Context
[ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)
made a licence an account the manager learns from what the nodes report, adopted by refreshing it, and
bound a node to a licence automatically only when it was bound to nothing (§7); every later change was a
person's act through `bind` and `switch`. It went live on 2026-10-04 with one account, bound to all four
nodes.
**The operator then asked what happens on a login to a second account on one node, and traced, the
answer was wrong.** The manager adopts the second account as a new licence — and leaves the node bound to
the first. The node is left holding the second account's access token, a refresh token the adoption just
spent, and a binding to the first; at the first account's next rotation it is handed a token its own state
file does not name. A login is the most direct thing a person does on a machine about which account it
uses, and the mesh read it as a contribution of a grant only.
**An API key could enter only from a file on the manager's node** (ADR 0206, design 39 §6), so adding one
meant reaching that machine. The operator asked for a streamlined process for both.
## Considered Options
1. **A login on a node switches that node to the account logged in to.** Chosen.
2. **Keep ADR 0206 §7, and have the person `switch` after logging in.** Rejected: the step is easy to
forget and the state between the login and the switch is the broken one described above.
3. **Refuse to adopt a login for an account other than the node's binding.** Rejected: it discards what
the person plainly meant, and a second account could then enter only by a separate act.
## Decision
**1. A login on a node is that node's choice of account.** When the manager adopts a node's login (ADR
0206 §4) — a new account, or a newer login of one it holds — it binds that node to the licence the login
belongs to. If the node was bound to another licence, this is a switch: the node is handed the new
licence's access token and its agent's account is pointed at it, as `switch` does. Every other node stays
where it is. `bind`, `switch` and `release` remain for moving a node without a login.
**2. A login that does not refresh moves nothing.** The candidate is recorded dead (ADR 0206 §4) and the
node keeps its binding; the person logs in again.
**3. An API key is added from any node, sealed, never as an argument.** The agent module serves a tool
that reads a key from a file on its own node, seals it to the manager's public key — which the seat now
serves as a verb — and hands it to the seat's `adopt` on request/reply; the file is removed once the
manager has taken it. Optionally the same call binds that node to the new licence. The seat's `adopt`
still also takes a file on the manager's node. An API key is a licence of its own, never an account's:
nothing is learned about it from a report, and it moves a node only when a person says so.
## Consequences
- Logging in on a node is the whole of moving that node to an account, new or known. The mesh's state
stays consistent: the binding, the token on the node and the account its agent names agree.
- A second account enters the mesh by one login, and only the node it was logged in on uses it.
- **What got harder:** a person who logs in on a node to try an account moves that node; moving it back is
`switch`. Said in the seat's own description of `switch`, and in the agent module's instruction file.
- The key file on a node exists only until the manager has taken it; the key then lives encrypted in the
manager's store alone (ADR 0183).
## How it is checked
| Rule | Checked by |
|---|---|
| A login for another account moves its node and no other | the manager's test: two accounts, the login on one node adopted, that node switched, the others unchanged |
| A newer login of a known account on a node bound elsewhere moves that node | the manager's test |
| A login that does not refresh moves nothing | the manager's test: the binding unchanged, the candidate dead |
| An API key never crosses the bus in the clear and its file is gone afterwards | the agent module's test: the request carries a sealed box only; the file is removed after the seat answered |
| Live | a login to a second account on one workstation: a second licence appears, that workstation is bound to it and its agent names it, the other nodes keep the first |
## References
- [ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md) — the flow this extends; §7 is changed by decision 1
- [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
- [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)
@@ -0,0 +1,115 @@
---
topic: the mesh
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md
---
# 210. A tool's configuration is its seat holder's, and every other module extends it through the seat
## Context
One fault kept coming back while the machines' modules were rolled out
([to-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)): two modules want the same
thing on a node.
- The bar module and the package manager's module both declared the package that brings the
package manager's helper scripts. The node stopped resolving
([issue 235](../04-ISSUES/235-an-assignment-that-cannot-be-composed-is-recorded-anyway/00-report.md)).
- The launcher, the clipboard manager, the wallpaper and the bar each wrote a file of their own into
the window manager's include directory, as [ADR 0208](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md)
§4 allowed. Nothing says those modules need the window manager. Assigned without it, they write
configuration nothing reads. Assigned with a different session holder, they write into a directory
that holder does not own.
- [ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md) §5
lets a module contribute to the environment on a node that has no holder: the contribution is
written nowhere, and nothing says so.
The mesh already has the piece that answers this.
[ADR 0207](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md) made a module depend on
the seat that applies its resources, derived from what it declares. A contribution is the same kind
of need. It is configuration that only the seat's holder can apply.
## Considered Options
1. **Let the first module to declare a file or package own it,** and refuse the second. Rejected:
ownership then depends on the order modules were written, and the second module has no lawful way
to say what it needs.
2. **Allow shared declarations** of one package or file by several modules, merged by the host.
Rejected: a shared file has no owner to answer for it, and removing one module cannot tell what it
alone put there.
3. **Each tool's configuration belongs to the module holding the tool's seat. Every other module
extends it with a contribution to that seat, and a contribution is a dependency on the seat.**
Chosen. It is the operator's statement of the rule.
## Decision
**1. One owner.** A tool's configuration files, and the tool's package, belong to the module that
holds the tool's seat on the node. Only that module writes them.
- The package manager's configuration and helper packages are the package manager's module's.
- The account's environment is node-environment's holder's.
- The window manager's configuration is node-display-session's holder's.
**2. Other modules extend, never write.** A module that needs something in another tool's
configuration declares a **contribution to that tool's seat**:
- its content, in the grain the seat defines (variables and paths, shell code for a named shell and
slot, a window-manager configuration fragment, a notifier rule);
- never a path inside the holder's files or directories.
The holder places what it receives:
- the controller renders the contributions into the holder's files through the holder's placeholders,
as [ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md) and
[ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md) already do;
- or the holder writes each contribution to its tool's own drop-in directory. That directory is then
the holder's resource, not the contributor's.
**3. A contribution is a dependency on the seat that receives it.** The controller derives it from the
contribution, as ADR 0207 derives one from a resource. It is met, checked and refused exactly as ADR
0207 §3 and §4 say:
- refused at `assign` when no module on the node holds the seat and the catalogue has a holder;
- refused at composition after the switch.
**4. Needing what another module's package delivers is the same.** A module that needs a program
another seat's holder installs does not declare that package. It depends on the seat, and through
the seat's verbs where they exist. One package is declared by one module on a node.
**5. A seat says what it receives.** A seat lists the contribution kinds its holder accepts. A
contribution of a kind the seat does not list is refused at registration.
## Consequences
- **ADR 0203 §5** no longer holds: a module contributing to the environment depends on
node-environment, and a node without the holder refuses it. The rest of that record stands.
- **ADR 0208 §4**: its first list, contributors placing their own files in another tool's directory,
is replaced by §2 above. The tool's grain stays the guide for what a contribution contains. The
`xinitrc` and `xresources` slots already work this way, and now carry a dependency on
node-display-server.
- **The desktop modules change:** the launcher, the clipboard manager, the wallpaper and the bar
contribute their window-manager lines to node-display-session instead of writing into the include
directory. The bar keeps relying on the package manager's helper scripts through node-package-manager.
- **The two kinds of collision cannot recur:**
- two modules declaring one package or one file;
- a contribution to a seat nobody on the node holds.
A composition that finds either is a fault in a module, not a state a node can be left in.
- **What got harder:** a seat that receives contributions must define their grain, and its holder must
place them. Each new kind is a small change to the controller's renderer or to the holder.
## How it is checked
| Rule | Checked by |
|---|---|
| A contribution derives a dependency on the seat that receives it | the controller's resolve tests |
| A contribution to a seat with no holder on the node is refused at `assign` when the catalogue has a holder | the same tests, and `assign` live |
| One package or file is declared by one module on a node | the controller's composition test, and `module check` across the catalogue |
| A contribution of a kind its seat does not list is refused | the catalogue check, which registration runs |
| No module declares a path inside another module's files or directories | the catalogue check |
## References
- [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),
[ADR 0208](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md)
- [Issue 235](../04-ISSUES/235-an-assignment-that-cannot-be-composed-is-recorded-anyway/00-report.md)
@@ -0,0 +1,122 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
---
# 211. A machine's power is a node seat, its moments take contributions, and its states are events
## Context
The laptop's module needs code to run around sleep:
- the GPU driver's own suspend and resume actions;
- a touchpad reset after waking.
It wrote drop-ins of its own into the service manager's sleep services, so it wrote into files that
belong to another tool's holder. [ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)
forbids exactly that. A second module wanting code after waking would do the same, and nothing would
order the two or say that either needs sleep to be handled at all.
The mesh also cannot tell a sleeping machine from a lost one. A laptop with its lid closed stops its
heartbeat exactly as a crashed machine does, and is reported "out of touch" either way.
[Research 028](../01-RESEARCH/028-the-meshs-output-channel/00-overview.md) would turn every closed lid
into an alert.
## Considered Options
1. **Each module writes its own sleep drop-ins,** as the laptop's did. Rejected by ADR 0210: no
owner, no order, no dependency.
2. **The service manager's holder takes power hooks.** Rejected: sleep and power are logind's and the
firmware's concern, not service management's. On a laptop they also include lid, power source and
battery, which the service manager knows nothing about.
3. **A power seat on every machine.** Its holder:
- owns the machine's power handling;
- places code that modules contribute for named moments;
- publishes the machine's power states as events.
Chosen. It was the operator's proposal.
## Decision
**1. `node-power` is a node seat in the mesh's own set.** One module per node holds it. Every
machine has one, servers included: every machine boots and shuts down. The first holder is a module
named `power`.
**2. Its holder owns the machine's power handling:**
- logind's power-key and lid settings;
- the hooks around sleep, boot and shutdown;
- the reading of power source and battery where the machine has them.
A model's specific values, such as what the lid does on that laptop, are the model's module's
contribution or a setting of `power` per node (ADR 0174), never a second writer of logind's
configuration.
**3. Modules contribute code for named moments.** The moments:
- after boot;
- before sleep;
- after waking;
- before shutdown;
- on mains power;
- on battery.
A contribution is POSIX shell code, written with ADR 0204's mechanism as ADR 0208 §4 did for the
session's start:
- a `shell` contribution whose `for` names the moment, in the `first`, `normal` or `last` slot;
- placed by the holder with `${shell:<moment>:<slot>}` in the scripts its own units run;
- run as root, in module order, each piece bounded in time, so that one module's hang cannot hold a
machine awake.
Per ADR 0210, a contribution for a moment depends on `node-power`.
**4. Its states are the holder's events, on the bus:**
- `booted`, `sleeping`, `woke`, `shutting-down`;
- `on-mains`, `on-battery`, `battery-low`, where the machine has a battery.
They carry the machine's role and a time, and nothing secret, so any node and the controller may
consume them.
- **`sleeping` is published before the machine sleeps.** The holder takes logind's delay lock,
publishes, and releases the lock once the bus has acknowledged, within logind's delay bound.
- **On waking,** the holder queues events until the bus is reachable, then publishes them in order.
**5. A machine that said `sleeping` is asleep, not out of touch,** until it says `woke` or misses its
expected return. The controller shows the state, and the output channel (research 028) does not
treat a sleeping machine as a fault.
## Consequences
- The laptop's module moves its sleep drop-ins into contributions:
- the GPU driver's suspend and resume actions before sleep and after waking;
- its touchpad reset after waking.
Its own files in the service manager's directories go.
- Assigning `power` to every machine is phase 1 of [to-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md).
- The controller gains the moments as contribution targets placed by `node-power`, and a node's
power state in what it shows about the node.
- **What got harder:**
- code that must run at a precise point inside the sleep transaction cannot be a contribution; the
GPU driver's own units are an example. Such code still declares its own units, and only the
request to run them is contributed;
- an event published around sleep depends on the network still being up. The delay lock buys the
time, and if the bus does not answer within it, the machine sleeps anyway and says so on waking.
## How it is checked
| Rule | Checked by |
|---|---|
| A moment's contribution derives a dependency on `node-power`, and lands in that moment's placeholder in module order | the controller's contribution tests |
| A contribution naming an unknown moment is refused | the catalogue check |
| One piece of hook code that hangs is ended after its bound, and the next still runs | the power module's tests over real child processes |
| `sleeping` is published before sleep and `woke` after, and a missed acknowledgement does not hold the machine awake | the power module's tests with a fake logind and bus, and a live suspend of the laptop |
| A machine that said `sleeping` is not reported out of touch | the controller's status test |
## References
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
[ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md),
[ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
[ADR 0208](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md),
[ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)
- [Research 028](../01-RESEARCH/028-the-meshs-output-channel/00-overview.md)
@@ -0,0 +1,106 @@
---
topic: the mesh
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
---
# 212. A seat says what it receives, and the machine's hotkeys are a seat
## Context
[ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)
decided that a tool's configuration belongs to its seat's holder, and that every other module extends
it with a contribution to the seat (§2), in a grain the seat defines (§5). The controller knows only
three such grains:
- the environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md));
- shell code in named slots ([ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md));
- the power moments ([ADR 0211](0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md)).
Each was a field of its own, with a renderer of its own. Two more appeared on the first workstation:
- **The window manager.** Four modules, the launcher, the clipboard, the wallpaper and the bar, wrote
files of their own into its include directory, and so did the laptop's model module. That is what
ADR 0210 forbids.
- **The keys the window manager never sees.** A laptop's vendor keys reach only a hotkey daemon, which
reads trigger lines (a key, a state, a command). The daemon's configuration was the laptop module's,
although the daemon is a general piece that more than one module has keys for.
A field and a renderer per grain would make every new seat a change to the controller's schema.
## Considered Options
1. **A field per grain,** as before. Rejected: the manifest and the controller grow with every seat
that takes contributions.
2. **Contributions as files in the holder's drop-in directory,** each contributor writing its own.
Rejected by ADR 0210: a path in another module's territory.
3. **One general contribution: a seat, a kind the seat receives, and text in the tool's own grammar.**
The seat lists the kinds it receives. The holder places each kind with one placeholder, and the
controller concatenates the contributions in module order, each under a comment naming its module.
Chosen.
## Decision
**1. A module contributes with `contributions`.** Each entry names:
- a **seat**;
- a **kind**, which that seat receives;
- **content**, text in the tool's own grammar, which the controller does not read.
**2. A seat lists the kinds it receives,** with the comment prefix of its tool's grammar. A
contribution of a kind its seat does not list is refused at registration.
**3. The holder places a kind with `${contribution:<seat>:<kind>}`** in its own files. The placeholder
is filled with every module's contribution of that kind on the node:
- in module order;
- each preceded by a comment line naming the module;
- empty when there is none.
A placeholder in a module that does not claim the seat is refused, as ADR 0204 refuses shell slots.
**4. A contribution depends on its seat** (ADR 0210 §3), derived and refused as
[ADR 0207](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md) says.
**5. Two seats receive first:**
| seat | kind | what it is |
|---|---|---|
| `node-display-session` | `config` | window-manager configuration lines: bindings, start-up commands, rules |
| `node-hotkeys` (new, node scope) | `trigger` | hotkey-daemon trigger lines: a key, a state, a command |
`node-hotkeys` is in the mesh's own set. Its holder runs the daemon that sees the keys the window
manager does not, and owns that daemon's configuration and service.
## Consequences
- The window-manager fragments become `config` contributions of their modules: the launcher, the
clipboard, the wallpaper, the bar and the laptop model. The window manager's module places them
instead of including other modules' files.
- A hotkey module holds `node-hotkeys`. The laptop's model module contributes its vendor keys
instead of writing the daemon's trigger file, and keeps only what is its own: the scripts the keys
run.
- The three earlier grains stay as they are. Folding them into this form is a later change, not
required by this record.
- **What got harder:** a contribution is text the controller does not read, so a malformed line
reaches the tool. The holder checks the composed file with the tool's own check where the tool has
one (the window manager's), before it reloads.
## How it is checked
| Rule | Checked by |
|---|---|
| A contribution names a seat and a kind that seat receives | the catalogue check, which registration runs |
| The placeholder fills with every module's contribution, in module order, each named | the controller's contribution tests |
| A placeholder outside the seat's holder is refused | the catalogue check |
| A contribution derives a dependency on its seat | the controller's resolve tests |
| `node-hotkeys` is a node seat of the mesh's own set | the seat table's tests |
## References
- [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 0208](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md),
[ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md),
[ADR 0211](0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md)
- [To-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)
@@ -0,0 +1,71 @@
---
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
---
# 213. The operator sets the agent's managed settings through the agent module, under the mesh's own keys
## Context
The agent module writes the agent's machine-wide managed settings file
([to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §2). It carries the mesh's
own keys only: the attribution convention of its repositories, the connectors kept beside the managed
tool servers, and the key-helper for an API-key licence. Every other key was left to the person's own
settings, so that the mesh never reverts a person's choice on a push.
That left no place for a rule the **operator** wants to hold in every session on a machine: what the
agent may do without asking, what it must never do, and what its unattended mode allows. These keys
are not preferences. They are policy about what an agent may do on the mesh. Set by hand in one
person's settings on each machine, they are unmanaged state the mesh cannot see, and the agent refuses
to change them itself, as it should.
## Considered Options
1. **Leave them to each person's settings.** Rejected: policy by hand on each machine, invisible to
the mesh, and a session cannot be asked to loosen its own permissions.
2. **A field per vendor key** (permissions, auto mode, environment, hooks) in the module's settings.
Rejected: the vendor adds keys, and every one would be a change to the module.
3. **One setting holding managed-settings keys, laid under the mesh's own.** The operator sets it
for the mesh or for one node through the controller's settings verb. The module copies its keys into
the managed settings file, then lays the mesh's keys over them.
## Decision
Option 3.
1. The agent module takes a setting, `managed_settings`: an object in the vendor's settings shape. It
is set for the whole mesh or for one node, like the module's other settings, through the
controller's settings verb.
2. The managed settings file is that object with **the mesh's keys laid last**: the attribution
convention, the connectors kept beside the managed servers, and the key-helper. A setting can
neither replace one of these nor add a key-helper that the binding did not ask for.
3. Only the operator sets it, and it is declared state like the role and the extra tool servers. A
person's preferences stay in their own settings; the mesh still sets none of them by itself.
## Consequences
- The operator's rules for the agent are declared once, for the mesh or per node, and reach every
node at the next push. A rule set in the managed settings outranks every other scope, so it holds
in every session on the node.
- A setting layer is replaced whole by the controller's verb. Setting this key without the role or
the extra tool servers clears those in that layer; the module's documentation says so.
- **What got harder:** a person cannot override a rule set here, which is the point. A rule that is
wrong is wrong on every session of the node until the operator changes the setting.
## How it is checked
| Rule | Checked by |
|---|---|
| The operator's keys reach the managed settings file | the agent module's test: an auto-mode allow list and a permissions list set in the setting appear in the rendered file |
| The mesh's keys always win | the same test: a setting naming the attribution, the connectors key or a key-helper is overridden, and a key-helper appears only for an API-key binding |
| Live | the setting given for the mesh; the managed settings file on each node carries the key after the next push |
## References
- [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the agent module and the files it writes
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) — the design this amends (§2, §6)
- the vendor's documentation on managed settings and their precedence
+6
View File
@@ -192,6 +192,8 @@ python3 00-META/checks/index.py fail if stale
- **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)
- **0210** — [A tool's configuration is its seat holder's, and every other module extends it through the seat](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)
- **0212** — [A seat says what it receives, and the machine's hotkeys are a seat](0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md)
### Its tiers, from the bottom up
@@ -228,6 +230,7 @@ python3 00-META/checks/index.py fail if stale
- **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)
- **0199** — [A module that answers names declares its zone, and a node's hosts file is one module's](0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)
### What runs on them, and how it gets there
@@ -307,6 +310,9 @@ python3 00-META/checks/index.py fail if stale
- **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)
- **0209** — [A login on a node moves that node to the account it logged in to; an API key is added from any node, sealed](0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md)
- **0211** — [A machine's power is a node seat, its moments take contributions, and its states are events](0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md)
- **0213** — [The operator sets the agent's managed settings through the agent module, under the mesh's own keys](0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)
### How it is built
+51
View File
@@ -0,0 +1,51 @@
---
layer: as-is
status: implemented
code: [mesh-controller internal/catalogue/state.go, mesh-controller internal/broker/state.go, mesh-controller cmd/mesh-controller/push.go, mesh-tools node-tools/internal/bus/state.go, mesh-tools node-tools/internal/launch/launch.go, mesh-sdk src/state, mesh-sdk go/state.go]
updated: 2026-10-04
decisions:
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
---
# A module's state, as it runs
**A module keeps the current value of something on the bus, and every machine sees it — including one
that joins later.** Since 2026-10-04 a manifest may say `state` (buckets the module owns) and `reads`
(another module's, as `<module>.<name>`). The first two modules to use it are the operator's agent on a
machine and its licence manager; on the day this was written, three buckets existed on the bus.
## What runs
- **The controller** creates a key-value bucket `<module>_<name>` for every declared state, from the
catalogue — on every start and, since the first module that declared state found it missing, on every
push before the memberships that name it. A bucket nothing declares any more is reported and kept.
Every bucket carries the mesh's caps: 256 KiB a value, 64 MiB a bucket.
- **The grants**: the machine's runtime is granted, for each bucket a module it carries owns, writing
under the bucket's own subjects and reading; for a bucket it only reads, reading. Measured once built,
with the composed grants loaded into a server: a reader's write is refused by the server.
- **The membership** issued to each assignment lists its buckets by the names the module uses, and
whether it may write.
- **The runtime** answers `mesh/state.get`, `put`, `delete`, `keys` and `watch` on the bundle's channel.
A watch hands the current values — none that is deleted — then every change, each naming the watch it
belongs to; it is answered once the current values are delivered. The runtime refuses, with the
reason, a state the module was not issued, a reader's write, a key the bus cannot hold, and a value
carrying a field named like a credential.
- **The SDKs**: `state(name)` in TypeScript, `stdio.State(name)` in Go (tag `go/v0.1.7` and later).
## What the first live use showed
- **A refused request is a timeout, not a refusal.** The bus reloads a machine's grants a moment after
the push that changed them; a bundle that asks in between waits out its deadline. A module that
watches at start therefore watches beside its handshake and asks again until the state answers — the
agent module needed two to seven attempts on its first start on each machine.
- **A late machine reads the whole set.** A server registered for every machine before one machine was
assigned the module reached that machine from the current values at its start.
- **The secrets guard is partial and works for what it covers**: an entry carrying an `Authorization`
header was refused on the live bus. A sealed value is plain text to an inspector, and is not caught.
## How it is checked
The controller's catalogue and broker tests (names, grants, memberships, a bucket asserted in place
against a real server); the runtime's tests over a real bus (current values without deletions, refusals,
the TypeScript SDK through the runtime); `module check` names a read whose owner on the shelf keeps no
such state.
@@ -0,0 +1,57 @@
---
layer: as-is
status: implemented
code: [mesh-catalog modules/claude-code, mesh-catalog modules/claude-licence-manager]
updated: 2026-10-04
decisions:
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md
---
# The operator's agent and its licences, as they run
**Every machine with an operator account runs the agent module, and one licence manager on the control
node keeps the licences.** Both are Go binaries the machine's runtime launches; neither has a container,
a port or a bus credential of its own. Live since 2026-10-04, on all four machines.
## The agent module on each machine
- **Writes the agent's managed directory**: the tool servers — the console as `mesh`, plus servers
registered through the module — the mesh's settings, and the instruction file. The tool-server list
is exclusive by the vendor's rule: a server not in it does not load on that machine.
- **Keeps registered tool servers in its state**, one key per registration for every machine or for one;
each machine renders what applies to it.
- **Reports what its machine holds** — the account its agent names, the kind, fingerprints and expiries,
never a token — at start and whenever the credentials file changes.
- **Writes what the machine should hold**: on a newer generation of its binding it asks the seat's
`current`, sealed to its own key, and writes the access token only. No machine holds a refresh token.
- **Hands over a login when asked**, sealed to the manager's key, and **adds an API key** from a file on
its machine the same way, removing the file once the manager has it.
## The licence manager on the control node
- **Holds the `anthropic-licence-manager` seat**: `licences`, `bindings`, `bind`, `switch`, `release`,
`refresh`, `usage`, `adopt`, `public-key`, `current`.
- **Learns licences from the reports**: a refresh token it does not hold is adopted by refreshing it,
newest login first, once per account. The machine a login was made on is moved to that login's
account; a machine bound to nothing is bound to the account it reports.
- **Is the only refresher**: every exchange under a lease per licence in its own database, every four
hours and in any case within an hour of expiry; grants are encrypted at rest with a key the vault made.
- **Publishes what each machine should hold** as its `bindings` state, with a generation that grows with
every rotation and switch.
## On the day it went live
One subscription account was adopted from the control node's own login on its first start; the other
three machines, logged in to the same account with older logins, were bound to it without their logins
being exchanged. A forced rotation reached all four machines within seconds. Two faults were found and
fixed during the rollout: a machine reporting an already-adopted account later was never bound, and a
seat verb named with an underscore was refused by the builder.
## How it is checked
Each module's own tests (the agent's instruction file held byte for byte to the renderer it replaced; the
manager's rules on a store in memory and a stub vendor; its store against a real database); one run of
both binaries under the real runtime with a stub vendor before going live; and live: `licences` lists the
licence with every machine bound, and each machine's `claude_code_status` names it with no login waiting.
+2
View File
@@ -22,6 +22,8 @@ Where the two disagree, the implementation wins and the disagreement is stated.
| [`11-the-lab.md`](11-the-lab.md) | The lab — the first piece of the new shape that exists, and what it does not yet do |
| [`12-the-seats.md`](12-the-seats.md) | The seats the mesh defines, who holds one, and where a seat changes resolution |
| [`13-the-console.md`](13-the-console.md) | The mesh's tools on the machine a person sits at, served by a module the mesh assigned there |
| [`14-a-modules-state.md`](14-a-modules-state.md) | A module's current state on the bus: what the controller creates, the runtime serves, and the first live use showed |
| [`15-the-agent-and-its-licences.md`](15-the-agent-and-its-licences.md) | The operator's agent on every machine and the licence manager that keeps its licences |
## What these documents are not
+18
View File
@@ -6,10 +6,16 @@ code:
- mesh-controller examples/route-proxy
- mesh-controller internal/identity/authority.go
- mesh-controller cmd/mesh-controller/plan.go (the names the roster publishes)
- mesh-controller internal/catalogue/zones.go (the zones a module answers, ADR 0199)
- mesh-controller internal/catalogue/seats.go (mesh-dns-resolver, node-hosts-file)
- mesh-catalog modules/dnsmasq (the mesh's one resolver)
- mesh-catalog modules/resolv-conf (what a node asks)
- mesh-catalog modules/hosts (a node's /etc/hosts)
- mesh-host internal/identity/serving.go
- mesh-host internal/apply (the service that reflects a rule set)
updated: 2026-10-03
decisions:
- 02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md
- 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
@@ -377,6 +383,16 @@ member's resolver answers a LAN; a router pointing at one is moved first. *Check
`/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.*
**Names that are neither a node nor a route.** A module that answers names declares a zone (a
setting) and the listen that answers it; the controller hands the `mesh-dns-resolver` holder every
zone with its module's node address and published port, and the holder forwards that zone there and
answers nothing in it itself — the lab answers `<machine>.incus` for its running scenarios this way.
An operator's own names, unrelated to the mesh, live in `/etc/hosts`'s kept region, held per node by
the `node-hosts-file` seat's holder and changed through its tools; the controller holds none of them
([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)).
*Checked by the holder's configuration carrying one forwarding rule per declared zone, and by a push
leaving the hosts file's operator region byte for byte.*
*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.*
@@ -1021,6 +1037,8 @@ The list is worth having in one place, because it is most of the argument:
- **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.
Nor are zones or the hosts file's holder ([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)): the
workstation moves to the one resolver only once both exist, its lab and operator names depending on them.
- ~~**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
+2
View File
@@ -12,6 +12,7 @@ code:
- mesh-catalog modules/gitea/module.json
updated: 2026-10-03
decisions:
- 02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md
- 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/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
@@ -129,6 +130,7 @@ convention, which later seats departed from.
| `mesh-build-machine` | `the-build-machine` | node | — | a builder |
| `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 |
| `node-hosts-file` | — | node | — | owns `/etc/hosts`: the machine's own lines and the operator's kept region, changed through its verbs `entries`, `add`, `remove` ([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)) |
| `mesh-intrusion-prevention` | `the-intrusion-prevention` | node | — | an intrusion-prevention service |
| `mesh-packet-filter` | `the-packet-filter` | node | — | the packet filter |
| `mesh-private-network` | `the-private-network` | node | — | the private network the mesh runs over |
@@ -1,9 +1,11 @@
---
layer: to-be
status: designed
code: []
status: in-progress
code: [mesh-catalog modules/claude-code]
updated: 2026-10-04
decisions:
- 02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md
- 02-DECISIONS/0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md
- 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/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
@@ -49,7 +51,7 @@ instruction file and the manager's tools:
|---|---|
| `~/.claude/CLAUDE.md` | the managed instruction file: how a session on this mesh works (§3) |
| `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md` | sections of the same file: this node's identity, the repositories' conventions |
| `~/.claude/settings.json`, merged | the managed settings file: the mesh's keys only, outranking nothing a person did not also set |
| `~/.claude/settings.json`, merged | the managed settings file: the mesh's keys, and the rules the operator set for the agent (§2) |
| `~/.claude/skills/hal-switch-license/SKILL.md` | the manager seat's `switch` verb, listed by the console, and a sentence in the instruction file saying to use it |
| `~/.claude/skills/cleanup/SKILL.md` | nothing; it named the predecessor's forge |
| the console's entry in the agent's user-scope state | the managed settings' tool-server key, from the console's provision (§4) |
@@ -67,7 +69,7 @@ lists them, and until they go the agent reads stale instructions beside the mesh
**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 and the console's endpoint, and a settings file carrying the
role and the extra tool servers, merged from the module's settings layers — the bundle is told the two
role, the extra tool servers and the operator's managed-settings keys, merged from the module's settings layers — the bundle is told the two
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
@@ -78,7 +80,7 @@ changes:
| path | content |
|---|---|
| the managed settings file | the mesh's keys: the tool servers (the console, plus any the operator declared as settings), the attribution trailers, and — for an API-key binding only — the key-helper that serves the key |
| the managed settings file | the keys the operator set in the module's `managed_settings` setting, with the mesh's keys laid over them: the tool servers (the console, plus any the operator declared as settings), the attribution trailers, and — for an API-key binding only — the key-helper that serves the key |
| the managed instruction file | §3 |
| the agent's credentials file under the operator's home | for a subscription binding only: the access token the manager handed over, as the operator, readable by the operator alone, atomic, no refresh token |
| the module's keypair in its state | made once, the private half never leaves (§5) |
@@ -93,6 +95,14 @@ requires. The model, the spinner, the drafts and every other preference are the
predecessor's experience with the model key is the evidence: a mesh that sets a preference reverts a
person's choice on every push.
**The operator's rules for the agent** ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)).
What the agent may do without asking, what it must never do and what its unattended mode allows are
not preferences: they are policy about an agent on the mesh, and a session must not loosen its own. The
operator sets them in the module's `managed_settings` setting, in the vendor's settings shape, for the
mesh or one node. The module copies those keys into the managed settings file and lays the mesh's keys
last, so a setting can never replace the attribution convention, the connectors key or the key-helper,
and a key-helper appears only for an API-key binding. The mesh still sets no preference by itself.
## 3. What the instruction file says
Prose, not a paste; the file is the module's.
@@ -159,6 +169,11 @@ decides it and [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-g
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;
- **adds an API key from this node** (*ADR 0209*): `claude_code_add_api_key` reads the key from a file
here, seals it to the manager's `public-key`, hands it to the seat's `adopt`, removes the file once
taken, and on request switches this node to the new licence;
- **follows a login made here**: a login to another account is adopted and moves this node to it (ADR
0209) — nothing for this module to do beyond reporting it;
- **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.
@@ -171,7 +186,8 @@ a token is fetched by request when the state says it changed.
**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)).
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, and the operator's managed-settings keys (§2). The controller's verb replaces a
setting layer whole, so a layer set for one of these keeps the others it already held. **Prerequisite:** the manager holds its seat and has adopted the licences.
**Order:** the manager assigned and a refresh observed; the console's provision in the catalogue; this
module on one workstation; the six predecessor files and the hand-made console entry removed there; a
@@ -198,6 +214,7 @@ installer is rejected: it puts a self-updating binary under the person's home, i
| on a lab machine with no account, the assignment is refused naming the fact | ADR 0181 |
| a switch asked of the seat through the console changes the licence and the token on the node; no tool answer and no log line holds a token | ADR 0183 |
| the API-key binding writes nothing under the home and the agent authenticates through the helper | ADR 0183 |
| the module's test: keys set in `managed_settings` (an auto-mode allow list, a permissions list) appear in the rendered managed settings file, a setting naming the attribution, the connectors key or a key-helper is overridden, and a key-helper appears only for an API-key binding | ADR 0213 |
| 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 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 |
@@ -1,9 +1,10 @@
---
layer: to-be
status: designed
code: []
status: implemented
code: [mesh-catalog modules/claude-licence-manager]
updated: 2026-10-04
decisions:
- 02-DECISIONS/0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md
- 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/0024-model-access-is-a-provision.md
@@ -138,12 +139,16 @@ report, and adopted by refreshing it.
- **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`.
- **A login moves its node** (*amended 2026-10-04 by [ADR 0209](../../02-DECISIONS/0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md)*): the node a login was
adopted from is bound to that login's licence — switched, if it was bound to another — and every node
bound to nothing whose report names an account the manager holds is bound to it. `bind`, `switch` and
`release` move a node without a login.
- **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
on the manager's node, never as an argument.
- **An API key** enters from any node (*ADR 0209*): the agent module there reads it from a file on its own
node, seals it to the manager's key (the seat's `public-key` verb) and hands it to `adopt`, removing the
file once taken — or `adopt` reads a file on the manager's node. Never an argument, never on a stream.
An API key is a licence of its own and moves a node only through `bind` or `switch`.
## 7. What it emits and serves
@@ -155,7 +160,8 @@ gone; a rotation or a switch is a new generation in the `bindings` state.
**The seat's verbs**, the contract every future holder must serve: `licences` (each with kind,
identity, expiry, failures, who is bound), `bindings`, `bind`, `switch`, `release`, `refresh` (now, one
or all), `usage` (current and history), `adopt`, and `current` (a consumer's token, sealed to the key the
or all), `usage` (current and history), `adopt` (a file on the manager's node, or a key sealed to its
`public-key` — ADR 0209), `public-key`, and `current` (a consumer's token, sealed to the key the
consumer sends — ADR 0206). The manager asks a node for a candidate grant by the agent module's own tool.
## 8. Settings
@@ -1,7 +1,7 @@
---
layer: to-be
status: designed
code: []
status: implemented
code: [mesh-catalog modules/claude-code, mesh-catalog modules/claude-licence-manager]
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
@@ -15,6 +15,9 @@ decisions:
- 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
- 02-DECISIONS/0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md
- 02-DECISIONS/0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
---
# 42. The machines' modules, in order
@@ -76,6 +79,15 @@ 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.
**Added 2026-10-04.** Two more modules for every machine:
- `power` holds `node-power` ([ADR 0211](../../02-DECISIONS/0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md)). Other modules contribute code for
its moments (after boot, before sleep, after waking, before shutdown, on mains, on battery), and it
publishes the machine's power states on the bus.
- `dbus` holds the message bus. Modules shipping D-Bus policies or services contribute them to it.
It shares curated events, never raw traffic. An upgrade never restarts the bus live: its package
waits for a reboot. A live restart in the middle of a full upgrade took down a workstation's
logins on the day this was written.
## Phase 2 — both workstations
In order:
@@ -93,6 +105,18 @@ In order:
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).
**Who writes what** is [ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md): a tool's configuration belongs to the
holder of its seat. The launcher, the clipboard manager, the wallpaper and the bar contribute their
window-manager lines to `node-display-session`, and the window manager's module places them. They do
not write into its include directory. Each contribution is a dependency on the seat that receives it,
so assigning one of them without a window manager is refused. The first versions, which still write
the include files themselves, move to contributions once the controller derives the dependency.
**Added 2026-10-04.** `triggerhappy` holds `node-hotkeys` on both workstations ([ADR 0212](../../02-DECISIONS/0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md)). The
laptop model's vendor keys become its contribution. The window-manager fragments of the launcher, the
clipboard, the wallpaper, the bar and the laptop model become `config` contributions to
`node-display-session`.
## Phase 3 — one machine model
The laptop's hardware module (vendor daemon, GPU mode, charge limit, logind, brightness and vendor keys)
@@ -1,8 +1,8 @@
---
status: open
status: resolved
opened: 2026-10-02
located-in: []
fixed-by:
located-in: [mesh-controller]
fixed-by: mesh-controller PR #270
amended-design:
---
@@ -0,0 +1,49 @@
# 195 — Diagnosis
## 2026-10-04
**The count had grown, and was still almost all noise.** Every push now opens with 137 users the mesh
"has minted no credential for", across four machines. Checked against the catalogue: six modules declare
an own secret named `broker`; every other module declares none.
**Where the line comes from.** The controller composes the bus's user list from its records: one user
for the controller, one per machine, one per live enrolment token, one per person — and one per module
assigned to a machine, whatever the module declares. Users with no minted credential are left out of the
written file and named in the line. A module with no `broker` secret can never be minted one: issuing
refuses it, because an account nothing reads is an orphan ([issue 078](../078-a-delivered-secret-is-accepted-under-any-name/00-report.md)).
So for those modules the user was composed only to be left out and reported, on every status, plan and
push.
**Why that is safe to stop.** Where the machine's tool runtime runs — every machine, now — the runtime
is the module's way onto the bus ([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
[ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)); its grants are the union of
what the modules it carries declare, and that is unchanged. Where no runtime runs, a module without a
`broker` secret cannot connect at all, and a user would not change that.
**What else read the module users.** Each module's durable consumer was derived from its own user. A
module carried by the runtime and declaring no `broker` secret would have lost its consumer, and the
runtime reads that consumer on the module's behalf (ADR 0198). The consumers are now derived from the
module users and from what each runtime carries, one per module and machine.
**Ruled out as still open.** The report's first real gap — a declared `broker` secret filled with a
generated value — was closed by [issue 203](../203-a-fresh-assignment-is-pushed-before-its-credential-exists/00-report.md): a push refuses to make one and names the verb that issues
it. The second — a module that emits with no way onto the bus — has no case on a machine where the
runtime runs, which is every machine now.
**Fix.** A module user is composed only for a module declaring an own secret named `broker`; the
consumers are derived as above. The written accounts file is unchanged, since the users dropped never
had a password. What the line names from now on is the real gap: a module that can read an account and
has not been issued one. Checked by the broker package's tests: no user for a module without an
account, and its consumer still made.
## Answers to the report's questions
- *Should a bus user be composed for a module that declares no `broker` secret?* No.
- *Is a `broker` secret ever correctly made by the generic generator?* No; issue 203 already refuses it.
- *Should a module that speaks on the bus be refused when it declares no `broker` secret?* Not while the
runtime carries it; left for a machine without one, where no case exists today.
## Resolved — 2026-10-04
Live on the control node the same day. The first push after the controller restarted named no users
without a credential, and the number of modules with a durable consumer was unchanged.
@@ -0,0 +1,60 @@
---
status: open
opened: 2026-10-04
located-in: []
fixed-by:
amended-design:
---
# 233 — A host without its package manager's configuration refuses the declaration that would restore it
## What was observed
2026-10-04, on a workstation. The package manager's module writes that tool's main configuration
file whole, keeping the file it found. A declaration that no longer named the module
([issue 234](../234-a-later-declaration-left-out-four-assigned-modules-and-a-machine-applied-it/00-report.md))
reached a host older than the fix that gives a written-over file back its original. The host
removed the file, and the kept original stayed where the host keeps such files.
From the next declaration on, the host refused every declaration whole:
```
refused a declaration: this is the arch host and pacman does not answer here. Either this machine
is not Arch, or its package database is broken: pacman exited 1: error: config file … could not be
read: No such file or directory
```
The machine stayed in that state for an hour and a half. It retried every five minutes and was
refused every time. Two declarations it refused would have repaired it:
- the current one, which names the package manager's module again and so writes the file;
- the one carrying the newer host, which gives a kept original back.
Neither could be applied. A person restored the kept original by hand, and the next push applied.
`status` showed the machine as `refused` with the error above. That is correct, but nothing said
that the mesh's own tools could no longer reach it.
## Why it matters beyond this instance
The host checks that the package manager answers before it applies anything. That check is right for
a machine that is not what the mesh thinks it is. Here the check depends on a file that the mesh's own
modules own and can remove. Once that file is gone:
- every module's change waits behind it, including the host's own upgrade;
- the only way back is a person on the machine;
- so a fault that one declaration caused, and a later declaration would fix, cannot be undone
through the mesh.
The same shape holds for anything the host probes before applying. If what it probes is one of
the mesh's own resources, one bad declaration can wedge the machine.
## Open questions
1. Should a probe that fails refuse the declaration whole? Or should it fail only the resources that
need the tool, and apply the rest (files, units, the host's own upgrade)? The rest may include
the very resource that restores the tool.
2. Should the host refuse to remove a file a seat's holder needs to answer, or warn before it does?
3. Is a machine that refuses every declaration for longer than one apply a fault the mesh raises by
itself ([issue 187](../187-the-mesh-tells-nobody-when-it-stops-working/00-report.md))? Today it
appears only to someone who asks for `status`.
@@ -0,0 +1,67 @@
---
status: open
opened: 2026-10-04
located-in: []
fixed-by:
amended-design:
---
# 234 — A later declaration left out four assigned modules, and a machine applied it
## What was observed
2026-10-04, on a workstation. Four modules (the package manager's, sudo, localization and the
container runtime's) had been assigned and pushed, and the host was applying them in one long apply,
about eleven minutes on a loaded machine. Seven more declarations arrived while it worked. When the
apply ended, the host logged six times `set aside a declaration: a newer one arrived with it` and
applied the one it kept.
The host chooses by the sequence a declaration carries
([issue 107](../107-a-declaration-carries-no-order/00-report.md)), so the one it kept was the latest
the controller had sent. That declaration did not name the four modules. Over the next eight minutes
the host undeclared all of them:
```
removed sudo.operator (…)
removed pacman.config (…)
forgotten pacman.package (pacman)
removed localization.locale (…)
removed docker.prune-service (…)
```
Throughout, the controller's assignments named the four modules on that machine, and they still do:
`plan` for the machine lists them. Nobody had unassigned them.
What happened around it:
- A catalogue asked the controller to catch up twice in the same minutes, and every builder rebuilt
the same catalogue commit.
- The controller daemon restarted three minutes after the stale declaration was applied.
- Another session was working on the mesh and pushing at the same time.
The controller logs no send. Which process sent the stale declaration, and from what view, cannot be
read back from anything the mesh keeps.
## Why it matters beyond this instance
A declaration is the mesh's word on what a machine should be, and the host applies it in full,
including removing what it does not name. A stale one is not harmless: here it removed four modules,
and on a host older than the fix for written-over files it deleted four system files outright
([issue 233](../233-a-host-without-its-package-managers-configuration-refuses-the-declaration-that-would-restore-it/00-report.md)).
[Issue 204](../204-a-controller-handover-re-sent-every-node-a-stale-declaration/00-report.md) was this
family once before and is marked resolved. Ordering by sequence (issue 107) protects against an old
declaration arriving late. It does not protect against a declaration that is new in sequence but
composed from an old view.
## Open questions
1. Where can a declaration be composed with fewer modules than the assignments hold? Candidates:
- a send from a process with an out-of-date view;
- composition while a module's build is being replaced;
- a plan sending what it composed when it was made.
2. Should every send be recorded with its sequence and its sender, so `status` can show what a machine
was last told and by whom?
3. Should a declaration that removes something carry a check the host can verify? For example, the
assignment generation it was composed from, so a host refuses one older than the generation it has
already applied.
@@ -0,0 +1,48 @@
---
status: open
opened: 2026-10-04
located-in: []
fixed-by:
amended-design:
---
# 235 — An assignment that cannot be composed is recorded anyway, and the node is one push from leaving the mesh
## What was observed
2026-10-04, assigning thirteen desktop modules to a workstation in one `assign`. The controller
answered `ok: false`. Its output first confirmed every module (`<node> is assigned <module>`, thirteen
times), then repeated the same reason eleven times:
```
<node> is not counted as on the network: it does not resolve: these assignments cannot be applied:
- i3status-rust and pacman both declare the package "pacman-contrib"
<node> is left out of the rest of the mesh: it does not resolve: …
```
and ended with the node's needs from the control node (artifact store, internal CA, package registry)
reported as unmet "because they are not both on the private network".
The thirteen assignments stayed recorded. `plan` for the node failed until one module was unassigned
by hand. Had anything pushed in that window — a person, a plan rolling out a rebuilt module, another
session — the mesh would have composed every node without this one on the private network, and this
node without its own declaration.
## Why it matters beyond this instance
[ADR 0207](../../02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)
says `assign` judges the modules together and refuses what cannot be met. It judges seats. A
collision found only at composition — two modules declaring one package, one file, one port — is
reported after the assignment is written, as if it were a warning. The result is the most dangerous
state a node can be in: assigned, unresolvable, and silently dropped by the next push of anything.
The reason was also hard to read: one fault, said eleven times, with three follow-on complaints that
point at the network instead of at the collision.
## Open questions
1. Should `assign` compose the node with the new assignments before writing them, and refuse the
whole call when composition fails? The node would then never become unresolvable through `assign`.
2. When a node does not resolve for any reason, should a push of other nodes keep its last composed
state in theirs rather than drop it from the private network?
3. Should one composition fault be reported once, with the follow-on unmet needs folded under it?
@@ -0,0 +1,37 @@
---
status: open
opened: 2026-10-04
located-in: []
fixed-by:
amended-design:
---
# 236 — The catalogue check passes a manifest the host refuses
## What was observed
2026-10-04. A login-manager module passed `mesh-controller module check`, was registered, built and
assigned. The first push of its declaration was refused whole by the host:
```
refused a declaration: this declaration is refused, and none of it was applied:
- resource "lemurs.service": a service that omits state leaves the unit's lifecycle to the
machine, and boot and takes-over are both its lifecycle
```
The rule is the host's declaration validation. The controller's check never applies it, so a manifest
can pass every check the catalogue has and still fail on the first machine that receives it.
## Why it matters beyond this instance
A refusal is whole, so one module with this fault blocks every other change for that node until the
module is fixed, rebuilt and pushed again. The fault is static: it is in the manifest, and could be
found before a module is merged. Today it is found by assigning the module to a live machine.
## Open questions
1. Should the host's declaration validation be importable, so that `module check` (and registration)
runs it over each resource the manifest declares?
2. Or should the controller validate the composed declaration before it sends it, and refuse to send
one the host would refuse?
3. Which other host-side rules are not visible to the catalogue check today?
@@ -0,0 +1,40 @@
---
status: open
opened: 2026-10-04
located-in: []
fixed-by:
amended-design:
---
# 237 — `assign` says a seat is held and that the node does not resolve for lack of it, in one answer
## What was observed
2026-10-04. A workstation already ran a module that contributes to `node-hotkeys`, and the module
holding that seat was assigned to it. The answer said, in order:
```
<node> is assigned triggerhappy
and asus-zephyrus-g14 on <node> now has node-hotkeys held
run `push <node>` to send it
<node> is left out of the rest of the mesh: it does not resolve: these assignments cannot be applied:
- asus-zephyrus-g14 on <node> depends on node-hotkeys, which nothing on <node> holds (novox/hq ADR 0207) — assign one that holds it: triggerhappy
```
Both statements cannot be true. `plan` for the node, run straight after, resolved: it held
`node-hotkeys`. A push applied all 300 resources.
## Why it matters beyond this instance
The last lines of an answer are the ones a person and an agent act on. Here they say the node is cut
off from the mesh and name the remedy as the assignment just made. A person would assign it again,
or stop the rollout. An agent following the instruction loops. The tail of `assign` comes from a
second view of the mesh, and that view was not the one the assignment had just changed.
## Open questions
1. Where does the "left out of the rest of the mesh" judgement read the node's assignments from, and
why did it miss the one just recorded: a cached resolution, a read before the write committed, or
the catalogue as registered before the contributing module's new version?
2. Should an answer that contradicts itself be impossible by construction, with every line of it
derived from one resolution taken after the write?
@@ -0,0 +1,40 @@
---
status: open
opened: 2026-10-04
located-in: []
fixed-by:
amended-design:
---
# 238 — The mesh banned its own operator's address for four weeks
## What was observed
2026-10-04. An agent working for the operator on a workstation polled the forge's ssh port in a loop,
about forty connections in ten minutes, waiting for a branch. The intrusion-prevention holder on the
control node banned the operator's home uplink address in the forge's jail for a day, and then in
`recidive` for four weeks.
From then on, nothing in the operator's home could reach the control node on the banned ports: not
the workstations, not the laptop. The `unban` verb of `node-intrusion-prevention` lifted it, once the
address was found in a ban list of over 400 entries.
## Why it matters beyond this instance
[ADR 0186](../../02-DECISIONS/0186-a-ban-list-never-holds-a-neighbour.md) says a ban list never holds a
neighbour. The operator's home address is the one address the mesh can be sure belongs to it. Every
machine behind it is a node, and the operator reaches the mesh from it. Yet nothing told the jails
so. A ban there locks the mesh out of itself, for longer than any repair takes, and the remedy needs
a path that does not go through the banned address.
The output channel being researched
([research 028](../../01-RESEARCH/028-the-meshs-output-channel/00-overview.md)) would not have said
anything either: a ban is not reported as an event.
## Open questions
1. Which addresses are the mesh's own? The public uplink of every node, as each node reports it, and
the operator's known addresses. Should every jail's ignore list carry them, derived rather than
configured?
2. Should a ban of an address any node reports as its own be refused, or at least emitted as an event
the output channel carries?