Compare commits

..
Author SHA1 Message Date
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
22 changed files with 1181 additions and 11 deletions
@@ -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.
@@ -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)
+4
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
@@ -307,6 +309,8 @@ 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)
### 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
@@ -1,9 +1,10 @@
---
layer: to-be
status: designed
code: []
status: implemented
code: [mesh-catalog modules/claude-code]
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/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
@@ -159,6 +160,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.
@@ -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)
@@ -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?