Compare commits

...
Author SHA1 Message Date
jochen 82fa5f79ea ADR 0206: a node reports the grant it holds; the manager adopts a licence by refreshing it
The operator's flow: clients publish what their credentials file holds, the
manager takes in a licence it does not own and rotates it from then on. The
token itself cannot be published (design 32 §10, ADR 0201), so a node reports
fingerprints and identity as state and hands the grant over only when the
manager asks; adopting is refreshing, newest login first; bindings with a
generation replace the rotated/switched events. Designs 36 and 39 and to-be 40
amended; a pointer note on ADR 0183.
2026-10-04 11:58:46 +02:00
jochen f6668d76d6 There is no home-scoped module: ADR 0181 and 0182 say so as progressive insights; design 36 and to-be 40: the module declares the two directories it owns
ADR 0173 §2: a module is what it declares, and there are no kinds of module. The two records called
a resource under a home and a module placing one home-scoped; the wording is corrected in place,
marked and dated, the decisions unchanged. Design 36 and to-be 40 now say the module declares
/etc/claude-code and ~/.claude as directories, so the ownership check sees both, and declares no file
under either (mesh-catalog #244).
2026-10-04 11:56:32 +02:00
jochen f5d54db7aa Plan and designs after ADR 0193, 0195 and 0198: bundles are launched and the runtime is their bus; the manager's daemon is a long-running bundle; the console's five tools
The dated note on ADR 0183 now rests on ADR 0193 and 0198 rather than on a bundle having no way to
call: the manager starts every exchange by the operator's direction, through mesh/ask. To-be 40's
WP4 no longer waits on a record — ADR 0198 is it — and the live proofs count the console's five
tools (ADR 0195).
2026-10-04 11:56:32 +02:00
jochen bcf010886d Design 36 §4: the console is registered in the exclusive managed tool-server file, because the managed-settings key refuses a non-https URL 2026-10-04 11:56:32 +02:00
jochen 2eba399e1e To-be 40 revised for the tools refactor; the manager starts every exchange (ADR 0183 dated note, designs 36 and 39)
Design 38's WP1-WP4b ran: the node's tool runtime is live on all four machines as the operator
account, tools are bundles given only their declared words, and a bundle has no bus credential.
So the wait on design 38 WP3 is over, the agent module calls nothing and the manager starts every
exchange (key, hand-over, waiting login, reconcile), and the manager's daemon now waits on WP4c's
record instead. Accounts are stated on all four, sudo -n works for each, the agent is installed on
all four; the plan's WP0 shrinks and WP2 gets a configuration-only live proof before any licence.
2026-10-04 11:56:32 +02:00
jochen d227ed12d2 To-be 40: building the operator's agent and its licence manager as work packages
Designs 36 and 39 say what is built; this says in which order and what proves each step, in the
shape to-be 38 gave the operator's machine. Seven packages: the operator states the facts (accounts,
roles, licences); the console provides its endpoint; the licence manager and the agent module are
built and unit-tested in parallel; the manager goes live on the control node; the agent on one
workstation, with the switch and the predecessor's files removed as the proof of the whole; then the
rest of the nodes and the retirement of the two catalogue modules built on the old placement. The
live proofs wait for to-be 38's WP3, because both modules' tools run in the node's tool runtime
(ADR 0175) and a per-module tool container would rebuild what that record retires.
2026-10-04 11:56:32 +02:00
mesh-admin a8ffc2b94b Merge pull request 'Research 025 graduated: ADRs 0203–0205, issue 228, to-be 41 (the shell and the account's environment)' (#351) from feat/the-shell-and-its-environment into main 2026-10-04 08:49:36 +00:00
jochen 1dcbdae1c4 Issue 225 → 228: the number was taken on main while this branch was open 2026-10-04 10:30:46 +02:00
jochen c3ec48f85c To-be 41 WP1: directories made inside a home belong to its account 2026-10-04 10:30:23 +02:00
jochen 72eda923c7 ADR 0205: the vendored theme's measured size 2026-10-04 10:30:23 +02:00
jochen c4fedcdbe3 To-be 41 WP1: a shell that refuses logins need not be listed; giving back is never fatal 2026-10-04 10:30:23 +02:00
jochen 0bf70ee8b4 Graduate research 025: the environment and the shell's contributions
ADR 0203: the account's environment is one module's (seat node-environment);
every module contributes variables and PATH entries, rendered by the
controller as a POSIX file and as environment.d.
ADR 0204: shell code is contributed to the login shell in named slots, and
login-shell becomes the mesh's node-login-shell.
ADR 0205: software the distribution does not package ships as a pinned
archive of the module.
Issue 225: undeclaring a user stops a node applying; the shell is never
given back or checked.
To-be 41 carries the work packages; to-be 38 WP5 points to it.
2026-10-04 10:30:23 +02:00
jochen 947b85af5e Research 025: an environment module holds the account's environment
The operator's proposal: one module, holding a mesh seat node-environment, is
the only writer of the account's environment. It renders contributed variables
and PATH entries as a POSIX file shells source and as environment.d for the
graphical session. The shell's contribution shrinks to shell code; the shell
render-then-service-manager-again positions become options weighed against it.
2026-10-04 10:30:18 +02:00
jochen ac6c306df3 Research 025: how a module plugs into the operator's shell
Opened after the zsh module's rollout (to-be 38 WP5) was stopped: every machine
carries the same predecessor-written startup file, the module's block would
duplicate it and drop lines, nothing installs the prompt, and execute never
reads .zshrc. Weighs how modules contribute environment and shell code, where
the operator's own lines go, ordering, and what a contribution is addressed to.
2026-10-04 10:30:18 +02:00
mesh-admin 96df3ccc88 Merge pull request 'ADR 0201 → 0202, and three issues from the night group 8 shipped' (#350) from issues/the-night-of-group-8 into main 2026-10-04 02:34:32 +00:00
jschoubben bc64c5c187 ADR 0201 → 0202, and three issues from the night it shipped
The derived-value record is renumbered a second time: the key-value-buckets
record took 0201 while this waited to merge, as the bundles record took 0188
before it. Both times free when chosen, taken by the time it landed. cycle.py
caught it; three repositories cite this record, so the number matters.

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

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

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

That last one is issue 224, recorded rather than fixed: the host's rule for a
stopped container is to replace it, and while-stopped is the first thing that
makes a stopped container intentional. Both candidate fixes are decisions with
their own cost. Nothing is worse than it was; the store has never collected.
2026-10-04 03:27:32 +02:00
jochen 6b4da63261 Research 024: the composed grants, checked against a server once built 2026-10-04 02:50:02 +02:00
jochen e1b0bbde91 Research 024 and ADR 0201: a module keeps its current state in key-value buckets
Events miss a machine that joins after them and replay history where only the
latest matters. A module now declares state it owns and reads; the controller
creates the buckets, the runtime serves them on the bundle's channel. Designs 32
and 25 amended; grants measured against a running server.
2026-10-04 02:36:59 +02:00
33 changed files with 2151 additions and 91 deletions
@@ -0,0 +1,152 @@
---
status: graduated
initiated: 2026-10-04
touches: [the bus, what a module declares, the tool runtime, the SDK, the bus grants, 03-DESIGN/01-to-be/25-the-bus-on-nats.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md]
became: [02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md, 03-DESIGN/01-to-be/25-the-bus-on-nats.md]
---
# 024 — State a module keeps on the bus
## What is investigated
A place on the bus where a module's own code keeps **current state** — not history — that every
machine sees, including a machine that joins after the state was written: put, get, delete, list and
watch, reached through the node's runtime the way a bundle already publishes, asks and subscribes
([ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)).
On NATS that is a key-value bucket. The questions are what a module declares, who creates the
bucket, what the grants are, what the runtime's verbs are, and what may never be stored.
## Why
The mesh carries two kinds of module traffic and a third is missing.
- **Events** land in the EVENTS stream: limits retention, seven days, ten thousand messages per
subject, a durable consumer per consuming module that replays what it missed. Never a secret
([design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10).
- **Requests** are core request/reply — tool calls, a bundle's `mesh/ask` — and are kept nowhere.
Neither is *the current value of something*. Two cases from the first module that needs it, the
operator's agent on a machine ([design 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)):
1. **An MCP server registered for every machine.** Registering emits an event every machine's copy
of the module consumes. A machine the module is assigned to *after* the registration has no
durable consumer yet — the consumer is created at assignment — so it never hears of it. Wanted
instead: one entry per server, for every machine or for one; every machine reads the whole current
set when it starts and watches for changes; unregistering is a delete; any machine can list it.
2. **Which licence a machine is bound to** ([design 39](../../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md)).
As events, a machine that was off for a day replays every rotation since and asks for a token
after each. It needs only the latest binding and its generation. The token itself stays on
request/reply and is never stored.
The design already expects this. [Design 25](../../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1:
"conditions and observed state in key-value buckets that anything may watch".
[Research 017](../017-a-mesh-that-heals-itself/01-the-intended-behaviour.md) wants a provisioner's
"what I applied" and a rotation's step kept in one rather than in memory. Nothing implements it.
## What exists, measured 2026-10-04
| | fact | where |
|---|---|---|
| streams | five kinds of mesh stream: CONTROL (work queue), NODES and ASSIGNMENTS (last per subject), EVENTS (limits: 7 days, 10 000 per subject), one work queue per seat that accepts | the controller's broker streams |
| the state relationship | [design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 already names *state* — 1:1, last per subject — and says it is "declared: the mesh's own". Two streams use it, both written by the controller. No module can declare it | design 32, the controller |
| key-value buckets | none, anywhere | all four code repositories |
| the runtime's bus verbs | `mesh/publish`, `mesh/ask`, `mesh/subscribe`; delivery back to the bundle is `mesh/event` | the runtime's launcher |
| the runtime's principal | one bus user per machine carries every assigned module; its grant is the union of theirs. That one module's code does not act as another is the runtime's to keep: it publishes under the module's own name by construction | the controller's grant composition, the runtime's bus |
| what a bundle is issued | a membership per assignment, last per subject, read directly by the runtime: where it serves, where it emits, what it reaches | ADR 0160 |
| who creates bus objects | the controller only — mesh streams on every raise, a seat's stream at registration, a module's consumer at assignment. No module reaches the JetStream API | design 25 §3 |
### What a key-value bucket needs from a grant, against a real server
Measured against nats-server 2.10 with the Go client the runtime already uses, a bucket created by
an unrestricted user and used by two users holding only the subjects below (`B` is the bucket):
| operation | subject published | writer | reader |
|---|---|---|---|
| bind to the bucket | `$JS.API.STREAM.INFO.KV_B` | yes | yes |
| get | `$JS.API.DIRECT.GET.KV_B.>` | yes | yes |
| put, delete | `$KV.B.>` | yes | **refused** |
| list keys, watch | `$JS.API.CONSUMER.CREATE.KV_B.>` — an ordered, ephemeral consumer | yes | yes |
| stop a watch cleanly | `$JS.API.CONSUMER.DELETE.KV_B.>` | yes | yes |
| answers | its own inbox, which every principal already subscribes | — | — |
*Checked again once built, 2026-10-04:* the grants the controller composes for two machines' runtimes —
one carrying the owner, one only a reader — were loaded into a server as composed, and each operation
was run as each runtime's user. The owner's did all of them; the reader's read, listed and watched,
and its put and delete were refused by the server.
Three things the measurement showed that reading the documentation would not have:
1. **A refused put is not an error to the caller; it is a timeout.** The server reports the
permission violation asynchronously, on the connection, and the client waits out its deadline
for an acknowledgement that never comes. So a runtime that relies on the grant alone tells a
bundle "timed out" for "you may not write this" — it must refuse first, from what the module was
issued, with the reason.
2. **A watch's current values include deletions.** A key deleted earlier arrives among the initial
values as a delete marker, before the end-of-current marker. A bundle asking "what is there now"
must not be handed those.
3. **Without the consumer-delete grant, stopping a watch hangs** until its deadline, and the
ephemeral consumer lingers on the server until it times out by itself.
### Whether the events shape is enough instead
Honestly compared, because a new primitive is a cost:
- **EVENTS cannot be made last-per-subject for some subjects.** Retention is per stream, and
JetStream refuses a second stream overlapping the first (verified and recorded in design 32 §3).
A state subject inside `mesh.mod.*.event.>` keeps EVENTS' seven days: a licence binding unchanged
for a week disappears.
- **A separate last-per-subject stream per module** is possible — it is exactly what a key-value
bucket *is* on the server: a stream with one message per subject, a rollup for purge, and direct
reads. Building it by hand gives up the client's get, list, delete and watch, which are the
operations both cases need, and would be the mesh writing NATS's own key-value layer again.
- **Consumers are the wrong reader.** A durable consumer per reading module is created at
assignment and replays from where it is; state wants "everything current, now, then changes",
which an ordered ephemeral consumer from the last value per subject gives and a durable does not.
So key-value is not a convenience over events; it is the state relationship design 32 already
names, opened to modules.
## Questions, and what this effort proposes
1. **What a manifest says.** `state` names the buckets a module owns, by local name — every
instance of the module may write them and read them. `reads` names another module's bucket as
`<module>.<name>`, read-only. Names only, never a bucket or subject (design 32 §1). A bucket's
options — how many past values it keeps, how long a value lives — are the owner's to declare,
the way a seat declares its own retention (design 32 §3).
2. **Scope.** One bucket per module per name, mesh-wide. A key may carry a machine by the module's
own convention (`all.<server>`, `<machine>.<server>`). A bucket per machine was considered and
not proposed: "list every server for every machine" becomes a walk over buckets, and the grant
could only narrow writes, which nothing asked for — every instance of the owner already writes.
3. **Who creates the bucket.** The controller, from the catalogue, on every raise — a bucket exists
from registration, like a seat's stream, so a reader can watch before the owner is assigned
anywhere. Never a module.
4. **The runtime's verbs.** `mesh/state.get`, `mesh/state.put`, `mesh/state.delete`,
`mesh/state.keys`, `mesh/state.watch`, each naming the bucket as the module named it. A watch
is answered once the current values are on their way, then each change is delivered to the
bundle as a `mesh/state` request it answers — current values first (no deletions among them), an
end-of-current marker, then changes. A child that restarts watches again, as it subscribes
again. The runtime refuses, with the reason, a bucket the module was not issued, and a write to
one it only reads.
5. **Secrets.** None in a bucket, sealed or not: a bucket is a stream (design 32 §10). Sealed values
are plain base64 and cannot be recognised, so the mechanical check is partial and said to be: the
runtime refuses a value carrying a field whose name says it is a credential (`password`,
`secret`, `token`, `authorization`, …), which catches the ordinary mistake and not a determined
one. For the first consumer this has a concrete consequence: an MCP server registered with an
authorisation header keeps that header out of the bucket.
6. **History, lifetime, size.** One value per key unless the owner says more; no expiry unless it
says one; a value at most 256 KiB and a bucket at most 64 MiB, the mesh's caps rather than a
module's. **A bucket outlives its module's assignment** — what a module stored is data, and data
outlives what declared it ([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md));
unassigning is not cleaning up. A bucket whose declaration is gone is reported, never removed.
7. **Events or state.** State (above).
## The work, once decided
1. A decision record, then design 32 (*state* becomes a relationship a module declares) and design
25 (key-value buckets are part of the bus) amended.
2. The controller: the manifest's two words and their registration check; buckets asserted on every
raise; the grants for owners' and readers' runtimes; the buckets issued in each membership.
3. The runtime: the five verbs, the watch delivery, the refusals; tested against a real server.
4. The SDK, TypeScript and Go: a small state surface over the verbs.
5. Proved on a running mesh with one small module, then handed to the operator's agent, whose
registered servers move from events to a bucket.
@@ -0,0 +1,94 @@
---
status: graduated
initiated: 2026-10-04
touches:
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
- 03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md
- 03-DESIGN/01-to-be/37-the-operators-machine.md
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
became:
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
- 03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md
---
# 025 — How a module plugs into the operator's shell
## What is investigated
The shell module writes the mesh's part of the account's shell startup file. But the shell is not the
only module that needs a line there. A prompt theme loads itself from it. A language version manager
sets a variable and sources its loader. A toolchain puts its directory on `PATH`. A desktop module
names the browser. Today all of these sit in one hand-written file, and the shell module as written
carries some of them in its own block and loads others only "if a module placed them". Nothing says
how they get placed.
This effort asks four things:
1. **How a module contributes to the shell**: what it declares, who composes it, and in what order it
lands.
2. **Where the environment lives.** Variables and `PATH` entries are facts about the account, not
lines of one shell's syntax. They should reach every shell (interactive or not), the login shell's
`execute` verb, and programs a graphical session starts.
3. **Where the operator's own lines go,** so that assigning the shell module loses nothing the
machine does today.
4. **Which part of a file the mesh owns.** ADR 0174 calls the kept region the operator's; the host and
to-be 38 implement the inverse (the mesh owns a marked block, and everything outside it is the
operator's). The record this becomes says which.
## Why
Rolling out the shell module (to-be 38 WP5) was stopped on 2026-10-04 after a review of what assigning
it would do. Measured in [01](01-what-the-shell-file-holds-today.md):
- Every machine carries the same predecessor-written startup file, so the module's block would be
appended after its own older copy and everything would run twice.
- The block drops lines the machines rely on today.
- Nothing installs the prompt theme or the plugins the block loads.
- The `execute` verb runs a non-interactive login shell, which never reads the file the block is
written into.
The operator's direction: other modules must be able to plug themselves into the shell; the prompt
becomes its own module; assigning the shell module must lose no functionality; and the environment,
`PATH` above all, needs an answer of its own.
## What it touches
- The manifest. A contribution to the shell is either a new use of the existing `contributes` /
`receives` pair or a new gathered field like `jails` (to-be 31).
- The controller's composition, if the controller assembles the text.
- The `login-shell` seat (ADR 0176): what a holder must do with what is contributed to it, and
whether a module or the mesh declares the seat. Possibly a new seat for the environment, beside it
and beside the service manager's (ADR 0177). Research 023 asks the related question of a seat
naming the files its holder owns.
- ADR 0174's wording of the kept region, and ADR 0182's classification of the paths under a home.
- The zsh module, and the modules this makes possible: an environment module, the prompt, a version
manager, a toolchain.
## Where it stands
The operator proposed a separate **environment module**: one module, holding a mesh seat of its own,
that alone writes the account's environment. It writes a file that shells source and the service
manager's user environment, from the variables and `PATH` entries every other module contributes to
it. That is the starting position for the environment ([02](02-how-a-module-plugs-in.md) §1, option
E6). It leaves the shell's contribution as shell code only (§2), addressed to the `login-shell` seat,
which moves into the mesh's own seat set beside the new `node-environment` (§6).
Graduated on 2026-10-04 with one change from the starting positions: the controller, not the
environment module's own code, renders the environment into the module's files, so that the result
is in the declaration before a machine applies it ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
option 6b).
## Documents
- [01 — What the shell file holds today](01-what-the-shell-file-holds-today.md): evidence.
- [02 — How a module plugs in](02-how-a-module-plugs-in.md): the options and the starting position.
@@ -0,0 +1,103 @@
# 01 — What the shell file holds today
Measured 2026-10-04 on the four machines of one installation: two servers and two workstations. All
four have the account's login shell set to zsh, zsh installed from the distribution, and a
predecessor-written startup file. The predecessor is retired, so nothing manages these files any more.
## The startup file is the same everywhere
The account's `~/.zshrc` is **byte-identical on all four machines**: 102 lines, one checksum.
`~/.zshrc.local`, which the last line of `~/.zshrc` sources, comes in **two variants**: one shared by
both servers, and one shared by both workstations. So the "per-machine" part is really a
per-*kind*-of-machine part.
The predecessor produced these from one module with two *flavors*: a prompt flavor and an
autocomplete flavor, each of which swapped in a different local file. Its install hook also:
- cloned the prompt theme and three plugins from their upstream repositories into `~/.zsh/`;
- installed fonts;
- changed the login shell.
On the workstations the theme and plugins are still on disk, left over and now owned by nothing. The
servers have none of them.
## What the 102 lines are
Sorted by who should own each line once the machine is modules:
| Lines today | What they are | Natural owner |
|---|---|---|
| `EDITOR`, `VISUAL`, `XDG_CONFIG_HOME`, `PATH` gaining `~/.local/bin` and two script directories | the account's environment | the shell's default, or the environment itself |
| `PATH` gaining a toolchain's directory | environment, for one tool | the toolchain's module |
| a version manager's directory variable plus sourcing its loader | environment *and* shell code | the version manager's module |
| two variables naming the operator's own script library | environment, the operator's own | the operator |
| a variable that turns off an agent's terminal-title handling | environment, for one tool | the agent's module |
| the terminal title hook, keybindings, `dircolors`, the `ls`/`grep` aliases, `ll`/`la`/`l`, a container-run alias, two disk-usage functions, two port aliases | interactive shell behaviour | the shell's default |
| the prompt's instant-prompt cache, the theme, the prompt's own configuration file | shell code, order-sensitive (instant prompt first) | the prompt module |
| autosuggestions, syntax highlighting (and, unloaded, an autocomplete plugin on disk) | shell code, order-sensitive (syntax highlighting last) | a plugin module, or the prompt module |
| sourcing `~/.zshrc.local` | the operator's hook | the operator |
The workstation variant of the local file adds:
- more environment: a desktop toolkit theme, a file manager's plugin list, `BROWSER`, `VISUAL`
overridden to a graphical editor, a language toolchain's binary directory on `PATH`;
- two pieces of shell code: one that pads the prompt to the bottom of the terminal under a display, and
one that sources a function file another module places;
- a hook sourcing a further per-node file.
The server variant holds only that last module-placed source line.
**Count:** a workstation runs 65 non-comment lines from the two files (53 shared, 12 local); a server
runs 54. Of a workstation's 65:
- about a quarter (15) are environment;
- about half are interactive defaults no other module cares about;
- the remaining quarter is other modules' code and hooks (a prompt, plugins, a version manager, an
agent's functions), loaded from the shell file only because there was nowhere else to put it.
## The shell module as written
The `zsh` module of to-be 38 WP5 (catalogue change, unmerged):
- writes one block, appended at the end of `~/.zshrc`, holding a subset of the shared file:
- its environment lines, minus the toolchain directory, the version manager and the agent variable;
- the title hook, keybindings and the most common aliases, minus the port aliases;
- guarded `source` lines for the theme and two plugins *if present*;
- the source of `~/.zshrc.local`.
- assigned to any of the four machines, appends that block after the identical lines already there, so
every line in it runs twice, `~/.zshrc.local` included.
- on the servers, the guarded prompt lines find nothing; nothing installs the theme anywhere.
## Which startup file reaches what
zsh's startup order, and what each path through it reads:
| started as | reads |
|---|---|
| interactive login (a console, ssh with a terminal) | `.zshenv`, `.zprofile`, `.zshrc`, `.zlogin` |
| interactive non-login (a new terminal window) | `.zshenv`, `.zshrc` |
| non-interactive login: `zsh -lc …`, what the `execute` verb runs | `.zshenv`, `.zprofile`, `.zlogin`, **not** `.zshrc` |
| non-interactive: a script, `ssh host command` | `.zshenv` only |
So an environment written into `.zshrc` reaches neither `execute` nor a script. The distribution's
system-wide login profile, which zsh's system `zprofile` sources, only ever *appends* to `PATH` when an
entry is missing. An entry the account's `.zshenv` puts first therefore survives a login.
A graphical session's programs (a launcher, a bar, a window manager's key bindings) are started from the
display manager and the service manager, not from a shell, and read none of these files. The service
manager's own place for the account's environment is `~/.config/environment.d/`. Today it holds nothing
on any of the four machines, so a program launched from the window manager does not see `PATH` entries
that a terminal does.
## What the mesh already has for "many modules, one file"
Measured over the catalogue's 69 module definitions:
| mechanism | used by | shape |
|---|---|---|
| `contributes` / `receives` | 28 contribute, 15 receive | A consumer contributes **facts** keyed by a requirement. The provider receives all of them as one file in the mesh's own format, and **renders them itself**. "The controller does not know what a reverse proxy is." |
| `listens` / `filtering` | 40 declare listens, 1 composes | The controller derives the whole firewall rule set from every module's ports and writes it where the holder asks. |
| `jails` / `jailing` | 3 declare, 1 composes | Each module supplies its jail **in the tool's own format**. The controller assembles them, sorted, into the one file the holder names. |
| `into: block` on a file | 2 | One module's marked region inside a file something else owns. Text outside the region is kept byte for byte. Placement is at the end, or at the start. |
None of these is a contribution of shell code or of environment today.
@@ -0,0 +1,197 @@
# 02 — How a module plugs in
Six questions, taken one at a time: the environment, shell code, the operator's own lines, order,
who renders, and what a contribution is addressed to. Each has the options weighed and a starting
position. The positions were set with the operator on 2026-10-04 and are what this effort tests, not
what it has decided.
## 1. The environment: variables and `PATH`
A variable or a `PATH` entry is a fact about the account. It holds whichever shell is the login shell,
and it is wanted by:
- every shell, interactive or not;
- the login shell's `execute`;
- a graphical session's programs.
[01](01-what-the-shell-file-holds-today.md) measures that `.zshrc` reaches only the first kind, and
only interactively.
| | option | for | against |
|---|---|---|---|
| E1 | Each module writes lines into the shell's rc file (today) | nothing new | misses `execute`, scripts and the graphical session; written in one shell's syntax, so a second shell module starts over |
| E2 | A module contributes environment facts (a variable and its value; a `PATH` entry and its position) **to the login shell**. The holder renders them into its shell's always-read file (`~/.zshenv` for zsh) | reaches every zsh, `execute` included; a contributor names no path and no shell | the graphical session sees none of it; the environment is tied to which module holds the shell; every shell module reimplements the same rendering |
| E3 | E2, and the service-manager holder (ADR 0177) renders the same facts a second time into `~/.config/environment.d/` | the graphical session sees the same `PATH` as the terminal | one fact set, two owners, two renderings that can disagree; the service manager's module gains a duty unrelated to managing services |
| E4 | One composed file in `environment.d` syntax, sourced by the shell with export-all | one file, two readers | ties the shell to the service manager's syntax, which is close to POSIX assignments but not equal (its `${VAR:-default}` and quoting rules differ); a value with a space breaks one reader or the other |
| E5 | Shells take the environment from the service manager's environment generator, which prints the merged `environment.d` | no file of the shell's at all | every shell depends on the service manager and starts a process on every start; the generator's output is unquoted, so a value with a space breaks it |
| **E6** | **An environment module.** A module of its own (working name `node-env`) holds a mesh seat, `node-environment`, and is the only writer of the account's environment. Every module contributes its variables and `PATH` entries to that seat. The holder writes them in each reader's format: a POSIX file of `export` lines that shells source, and the service manager's `~/.config/environment.d/` | the environment no longer depends on which shell holds `login-shell`; one owner and one rendering per format, both from the same facts; the graphical session included without the service manager's module; a contributor addresses "the environment", never a shell; the `PATH` rules (order, de-duplication) live in one module's code, where a test can hold them | one more module and seat, assigned on every node beside the shell; the `login-shell` protocol gains a duty, to source the environment file, which must be written down and checked |
**Starting position: E6.** It was the operator's proposal on 2026-10-04, and it replaces this
document's first position (E2, then E3).
- The facts are the contribution. Each format is rendered once, by the one module whose subject is the
environment.
- A shell module's part shrinks to one line in its always-read file: `.zshenv` for zsh, sourcing the
environment module's POSIX file. A bash or fish module writes the same line in its own file, and no
contributor changes when the login shell does.
Sketched, for a node with zsh, the environment module, and a toolchain:
```
toolchain ──contributes PATH entry──▶ node-environment ◀──contributes EDITOR, ~/.local/bin── zsh
│ (held by node-env)
┌──────────────────┴──────────────────┐
▼ ▼
POSIX export file ~/.config/environment.d/
▲ ▲
sourced from ~/.zshenv read by the service manager
(every zsh, execute too) (the graphical session)
```
The shell module still contributes its own environment (`EDITOR`, `XDG_CONFIG_HOME`, `~/.local/bin` on
`PATH`) as a contributor like any other; it does not write those lines itself. Once issue 168 closes,
the values a person varies become settings of whichever module contributes them (ADR 0174).
## 2. Shell code: a prompt, plugins, a version manager's loader
This *is* one shell's syntax, and order matters: a prompt's instant-prompt cache must run first, and
syntax highlighting last.
| | option | for | against |
|---|---|---|---|
| S1 | **A contribution of code for one shell** (the shell it is for, the code, a slot), gathered by the controller and placed inside the holder's block in slot order. The same shape as `jails`, which a module supplies in fail2ban's own format and the controller assembles | a contributor names no path; the order is declared and checkable; unassigning the contributor removes its code at the next composition; a node holding fish simply has no zsh code rendered, and the resolver can say so | the controller gains one more gathered field; code for a shell travels in the declaration (in the clear, so no secrets in it, as for any file) |
| S2 | **A drop-in directory**: each module places its own `~/.zsh/rc.d/NN-name.zsh`, and the shell's block sources the directory | no controller change; each file is its module's own, removed when undeclared | every contributor hard-codes a path inside the shell module's territory, against ADR 0112's spirit; order is a naming convention nothing checks; nothing ties the file to the shell actually being zsh |
| S3 | Contributions as facts the holder renders (`contributes`/`receives` proper) | one mechanism with question 1 | code is not a fact; the holder would only paste it, which is S1 with an extra file |
**Starting position: S1.** A contribution to the shell carries **only code**, for named shells, each
piece in a slot. Variables and `PATH` entries never go here; they go to the environment (§1). So a
module touching both makes two contributions:
- A prompt module contributes zsh code in the first slot, and its own configuration file is its own
owned file (ADR 0182).
- A version manager contributes its directory variable to the environment, and its loader as code
for each shell it supports.
- A toolchain contributes a `PATH` entry to the environment and nothing to the shell.
What has to be settled: what each contribution is *addressed to*. Section 6 covers that.
## 3. The operator's own lines: the "local override"
Assigning the shell module must lose nothing the machine does today. That has two halves.
**What is common is the module's default, not an override.** The startup file is identical on all four
machines ([01](01-what-the-shell-file-holds-today.md)). A line every machine has is the shell module's
default, or another module's contribution. It is not a local override that a person would keep in step
on every machine by hand. Most of today's file therefore moves into the shell module's block and into
the contributions above. Little of it stays the operator's.
**What is the operator's is everything outside the mesh's block.** The host already works this way:
- the mesh's region is the marked block;
- text outside it is kept byte for byte, and checked unchanged;
- the region is given back when the module goes.
| | option | for | against |
|---|---|---|---|
| O1 | The mesh's block at the **start** of the file; the operator's lines after it | the operator's lines run last and win, which is what an override means; already supported (`at: start`) | a file the operator later rewrites must keep the markers; the host refuses a broken pair rather than guess |
| O2 | A named operator region *inside* a file the mesh writes whole (ADR 0174's wording) | the file is entirely the mesh's except one hole | the opposite of what the host implements; a file a person already owns becomes the mesh's |
| O3 | Only `~/.zshrc.local`, sourced from the block; `~/.zshrc` the mesh's whole | one obvious place | takes over a file the person owns today; ADR 0182 classifies the shell's own file as *written into*, not owned |
**Starting position: O1.** `~/.zshrc.local` keeps working because the operator's own lines source it,
not because the mesh's block does.
The record this effort becomes corrects ADR 0174's description of the kept region as a **progressive
insight**: the decision stands (a node varies a module by settings or by the operator's own lines,
never by an edit), and only its description of which side is marked changes.
**The one-off migration** is a person's act, listed in the module's documentation (ADR 0182):
- remove from today's file every line the block or a contribution now carries;
- keep the rest below the block.
Until a prompt module and the other contributors exist, the lines they will carry stay among the
operator's own. Nothing is lost at any step.
## 4. Order
Order matters only for code. The environment is set before any code runs, because zsh reads
`.zshenv` first. `PATH` entries carry their own position (before or after the system's), which the
environment module orders, not the shell.
| | option | for | against |
|---|---|---|---|
| R1 | Numbers (`10`, `50`, `90`) | familiar | every contributor guesses a number; collisions are silent |
| R2 | **A few named slots**, `first` / `normal` / `last`, with the module name breaking ties | the prompt says `first` and highlighting says `last` because that is what they mean; the composed result is the same bytes every time | three slots may not be enough |
**Starting position: R2.** Inside the shell module's block, the order is:
1. the line sourcing the environment module's file (in `.zshenv`, so it runs for every zsh; the rest
of this list is `.zshrc`);
2. the `first` slot;
3. the shell module's own defaults;
4. the `normal` slot;
5. the `last` slot.
The operator's lines come after the block, as option O1 says.
## 5. Who renders: the controller or the holder's code
There are two different renderings, and E6 lets them be answered differently.
**The environment** is facts rendered into two fixed formats by the one module whose subject they are.
- The environment module receives the gathered contributions (the `contributes` / `receives` shape:
facts in the mesh's own format, rendered by the receiver).
- Its own code writes the POSIX file and the `environment.d` file whenever what it receives changes.
That is ADR 0182's third class, written by the module's own process, owned by the account,
atomically.
- The controller learns no shell and no service manager. The `PATH` rules (prepend or append,
de-duplicate, keep the system's entries) are ordinary code with ordinary tests.
- To settle: what runs that code when the received file changes. The candidates are a host action
that restarts on the received file, or a subscription through the runtime (ADR 0198).
**Shell code** is not facts. It is text in the shell's own syntax, assembled in slot order, which is
what the controller already does for fail2ban jails: sort the pieces and concatenate them into the
holder's region. The controller assembles; it never interprets the code. This keeps the shell module
bundle-free for its files, and keeps the composed result visible in the declaration before a machine
applies it.
## 6. What a contribution is addressed to
Under E6 there are two addressees: the environment and the login shell.
| | option | for | against |
|---|---|---|---|
| A1 | **Seats**: environment facts to `node-environment`, shell code to `login-shell`. Each seat's protocol says what its holder does with what is contributed to it | a contributor depends on a role ("the environment", "the login shell"), never on zsh or on one module; works the same for any holder | `login-shell` today is declared by the zsh module itself (ADR 0126). A second shell module may only claim it, never declare it, and the seat exists only while zsh's definition is registered |
| A2 | Requirements the modules provide (`contributes` keyed by them, as the reverse proxy is) | an existing mechanism | a contributor on a node without the provider fails to resolve, though a toolchain's `PATH` entry with no environment module is merely unwritten |
**Starting position: A1, both seats in the mesh's own seat set** beside the service manager.
- `node-environment` is new, and is the mesh's from the start.
- `login-shell` moves there from the zsh module's definition. A shell is as universal a role as a
service manager, and a protocol that now carries duties (render the shell code contributed to it,
source the environment file) should not depend on one module's registration.
Research 023 (a seat's protocol naming what its holder owns) is the general form of this: the
environment seat would own the two environment files, and the login-shell seat the shell's
startup-file region. The two efforts should not decide it twice.
## Open questions
- Whether a contribution may be conditional on a capability: the workstation-only environment (a
browser, a toolkit theme) is a desktop module's contribution, which arrives only where that module is
assigned. Measured, this may need nothing new.
- What a node without the environment module does with environment contributions: refuse them at
resolve, or leave them unwritten and say so. The position here is to say so; a missing `PATH` entry
is a visible gap, not a broken machine.
- Whether the operator's own variables (the script-library paths in [01](01-what-the-shell-file-holds-today.md))
are the operator's lines below the shell block, or a kept region of the environment module's file.
The first needs nothing new, but reaches only interactive zsh.
- How the prompt module and a plugin module divide the plugins. Packaging decides it as much as
ownership: the plugins come from upstream repositories, not distribution packages, on these machines.
- Whether the `execute` verb should read the interactive file at all once the environment is in
`.zshenv`. The position here is no: a non-interactive login shell plus the environment is what a
command needs, and the prompt's code should not run for it.
- How a contribution reaches a second shell assigned beside the holder, which to-be 38 WP5 names as the
first follow-up record. Under A1 a non-holder renders nothing, so the question becomes whether a
non-holding shell module may render contributions for interactive use.
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
# 174. A node varies a module through settings and kept regions, never through an edit # 174. A node varies a module through settings and kept regions, never through an edit
> **The mechanism changed — 2026-10-04, by [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md).** Where this record calls a kept region *a marked block in which the operator's own lines are kept*, read the inverse, which is what the host built: the mesh's region is the marked block, and every line outside it is the operator's, kept byte for byte and given back when the module goes. The decision stands: a node varies a module by settings and by the operator's own lines, never by an edit.
## Context ## Context
[ADR 0011](0011-managed-files-are-generated-never-edited.md) says a managed file is derived and an [ADR 0011](0011-managed-files-are-generated-never-edited.md) says a managed file is derived and an
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
# 176. The login shell is a node seat held by one shell module, and `execute` is its contract # 176. The login shell is a node seat held by one shell module, and `execute` is its contract
> **The mechanism changed — 2026-10-04, by [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md).** The seat is no longer declared by the shell modules (§1). It is `node-login-shell`, in the mesh's own seat set, which a shell module claims. Its holder also places the shell code other modules contribute, and sources the account's environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)). What stands: one holder per node, the login shell set by the `user` shape and given back, `execute` as the contract, and any node may call it.
## Context ## Context
[ADR 0040](0040-what-a-module-is.md) names the shell as its example of a *shared* seat: bash, zsh [ADR 0040](0040-what-a-module-is.md) names the shell as its example of a *shared* seat: bash, zsh
@@ -9,6 +9,12 @@ extends: 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
# 181. The operator account is a node fact, and a home is a placement root # 181. The operator account is a node fact, and a home is a placement root
> **Progressive insight — 2026-10-04.** This record called a resource under a home *home-scoped*, and a
> module that places one a *home-scoped module*. There is no such kind of module
> ([ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) §2: a module is
> what it declares), so the three places now say *a resource placed under a home* and *a module placing
> files under a home*. What was decided is unchanged.
*Reconstructed. The controller shipped this on 2026-09-27 and *Reconstructed. The controller shipped this on 2026-09-27 and
[to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) recorded it as built without a [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) recorded it as built without a
decision behind it. This record states what was decided, from the code and the design, and adds the decision behind it. This record states what was decided, from the code and the design, and adds the
@@ -35,7 +41,7 @@ its home; the account and its home are machine facts a definition may name in a
and content; a roster file may say it lives under the home, and is then rendered per node, placed under and content; a roster file may say it lives under the home, and is then rendered per node, placed under
that node's account's home, owned by the account, and left out on a node with no account. On that node's account's home, owned by the account, and left out on a node with no account. On
2026-10-02 **all four nodes of the live mesh carry an empty account**: the fact exists and nobody has 2026-10-02 **all four nodes of the live mesh carry an empty account**: the fact exists and nobody has
stated it, so no home-scoped resource can land anywhere yet. stated it, so no resource placed under a home can land anywhere yet.
## Considered Options ## Considered Options
@@ -68,7 +74,7 @@ account. A definition names the account and its home as machine facts, never as
may say it is a home file and is then placed and owned the same way. The controller resolves both at may say it is a home file and is then placed and owned the same way. The controller resolves both at
composition, and the host chowns what it creates. composition, and the host chowns what it creates.
**A node with no account cannot carry a home-scoped resource, and says so.** A roster fact that lives **A node with no account cannot carry a resource placed under a home, and says so.** A roster fact that lives
under the home is left out of that node's declaration rather than written to nowhere. A resource naming under the home is left out of that node's declaration rather than written to nowhere. A resource naming
the account fact on such a node is refused at composition, naming the fact the machine does not have. the account fact on such a node is refused at composition, naming the fact the machine does not have.
A module that writes a person's files is thereby unassignable to a machine with no person on it, which A module that writes a person's files is thereby unassignable to a machine with no person on it, which
@@ -80,7 +86,7 @@ anything.
## Consequences ## Consequences
- **The operator states the account before any home-scoped module lands.** Today none is stated, so the - **The operator states the account before any module placing files under a home lands.** Today none is stated, so the
first assignment of such a module begins with four node records. first assignment of such a module begins with four node records.
- The roster carries each node's account, so a composed ssh configuration logs in as the right person - The roster carries each node's account, so a composed ssh configuration logs in as the right person
on every machine — the gap that surfaced this, closed by the same fact. on every machine — the gap that surfaced this, closed by the same fact.
@@ -9,6 +9,12 @@ extends: 02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-
# 182. Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found # 182. Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found
> **Progressive insight — 2026-10-04.** This record said *a home-scoped module* and *the family of
> home-scoped modules*. There is no such kind of module
> ([ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) §2), and the rule
> is about a directory under a home, whichever module declares it; the three places now say so. The
> decision, its options and its consequences are unchanged.
## Context ## Context
[ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) lets a module [ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) lets a module
@@ -35,7 +41,7 @@ use tools that no longer exist. Nothing owns them; nothing will ever rewrite or
`~/.ssh`: the mesh owns the directory and the files it places; it holds the person's private keys and `~/.ssh`: the mesh owns the directory and the files it places; it holds the person's private keys and
personal drop-ins as found. That was argued from the lockout `~/.ssh` can cause. The argument here is personal drop-ins as found. That was argued from the lockout `~/.ssh` can cause. The argument here is
the same shape with a different stake — the person's work rather than the person's way in — and it has the same shape with a different stake — the person's work rather than the person's way in — and it has
to hold for every directory the family of home-scoped modules will touch, so it is a rule, not a to hold for every directory under a home that any module will touch, so it is a rule, not a
section. section.
## Considered Options ## Considered Options
@@ -52,7 +58,7 @@ section.
## Decision ## Decision
**A home-scoped module owns the directory it declares: its existence, owner and mode.** The host creates **A module that declares a directory under a home owns that directory: its existence, owner and mode.** The host creates
it if absent, owned by the account, and never removes it while it holds anything it if absent, owned by the account, and never removes it while it holds anything
([ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)). Inside it, every path the module touches ([ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)). Inside it, every path the module touches
is in exactly one of four classes, and **the class is visible in the definition from the shape is in exactly one of four classes, and **the class is visible in the definition from the shape
@@ -105,7 +111,7 @@ finished its definition.
| An owned file found with no record is kept once, then written | host tests of ADR 0102's kept-original rule | | An owned file found with no record is kept once, then written | host tests of ADR 0102's kept-original rule |
| Only the declared keys of a written-into file change, and are given back | host tests of ADR 0102: declared keys set, the rest kept, restored when undeclared | | Only the declared keys of a written-into file change, and are given back | host tests of ADR 0102: declared keys set, the rest kept, restored when undeclared |
| Nothing found is touched | the family's lab check: a machine with a seeded home holding a person's file beside a predecessor's; after apply the person's file is byte-identical, the predecessor's is kept as the original, the mesh's keys are set and the person's keys in the same file remain; after unassign the mesh's files are gone, the keys are restored, the person's files are untouched and the directory stands | | Nothing found is touched | the family's lab check: a machine with a seeded home holding a person's file beside a predecessor's; after apply the person's file is byte-identical, the predecessor's is kept as the original, the mesh's keys are set and the person's keys in the same file remain; after unassign the mesh's files are gone, the keys are restored, the person's files are untouched and the directory stands |
| Every path a home-scoped module touches is classified | a catalogue review rule for this family: each path is a directory, a file, a file written into, a secret-and-step, or absent — the first module written to it is [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) | | Every path a module touches under a home is classified | a catalogue review rule for this family: each path is a directory, a file, a file written into, a secret-and-step, or absent — the first module written to it is [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) |
## References ## References
@@ -152,6 +152,30 @@ the node is bound to, and refuses with a notification otherwise.
| An unservable binding refuses rather than lends | a manager test: a worker bound to a dead licence is answered with a refusal, never another licence's token | | An unservable binding refuses rather than lends | a manager test: a worker bound to a dead licence is answered with a refusal, never another licence's token |
| A switch through the console changes the token on the node and nothing in the answer is a token | a live check on one workstation | | A switch through the console changes the token on the node and nothing in the answer is a token | a live check on one workstation |
> **The mechanism changed — 2026-10-03, by [ADR 0193](0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)
> and [ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md).**
> What stands: one manager holding the seat, one rotation source, a token sealed to the receiving
> module's key on request/reply and never an event, the agent module alone writing what the agent
> reads, the identity guard, the host knowing nothing. What moved: both modules' code is bundles the
> node's runtime launches over stdio and is the bus for — `mesh/ask` for a call made on the module's
> behalf, `mesh/publish` and `mesh/subscribe` beside it — so neither holds a bus credential of its own.
> The manager's refresh and visits are a long-running bundle the control node's runtime launches. And,
> by the operator's direction, **the manager starts every exchange**: it asks each bound node's agent
> module for its public key, hands it a token, asks it for a login waiting to be adopted, and reconciles
> every node on a schedule — which is what "the agent module asks the seat for its current token" and
> "offers the grant to the manager" in the decision above now mean in practice. The agent module could
> ask through its runtime; it does not need to.
> **The mechanism changed — 2026-10-04, by [ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md).**
> What stands: the manager holding the seat, one rotation source, the grants encrypted in its store, a
> token sealed to the receiving module's key on request/reply and never an event, the agent module alone
> writing what the agent reads, the identity guard, bindings as a person's act. What moved: the dated note
> above — the manager no longer starts every exchange. Each node reports what it holds as state, without
> the secret; the manager asks a node for its grant only when a report shows one it does not hold, adopts
> a licence by refreshing it rather than into a licence configured beforehand, and keeps what each
> consumer should hold as state, from which the node fetches its token by request. The rotation and switch
> events are gone.
## References ## References
- [ADR 0024](0024-model-access-is-a-provision.md), [ADR 0050](0050-model-access-is-vendor-agnostic.md) — the licence as a named thing, the carve-out this moves with the manager - [ADR 0024](0024-model-access-is-a-provision.md), [ADR 0050](0050-model-access-is-vendor-agnostic.md) — the licence as a named thing, the carve-out this moves with the manager
@@ -107,6 +107,16 @@ unreferenced.
- The store is briefly unavailable each night, for as long as collection takes. Everything that - The store is briefly unavailable each night, for as long as collection takes. Everything that
pulls from it retries; nothing in the mesh treats a momentary store as a failure pulls from it retries; nothing in the mesh treats a momentary store as a failure
([ADR 0185](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md)). ([ADR 0185](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md)).
- **An apply arriving during the window reopens it**, because the host's rule for a container it
finds stopped is to replace it, and `while-stopped` is the first thing that makes a stopped
container intentional. Found by reading this before it merged, recorded as
[issue 224](../04-ISSUES/224-an-apply-reopens-a-maintenance-window-by-recreating-what-it-held-still/00-report.md)
rather than fixed here: the two candidate fixes — the window takes the apply lock, or the apply
learns which containers are held — are each a decision with its own cost, and neither belongs
inside this record. Nothing is worse than it was; the store has never collected at all.
- **The sweep is bounded**: at most two hundred artifacts and sixty seconds per build, stopping at
the first refusal, because it runs inside somebody's build. What is left over is offered again
next time. The store stops growing from the first sweep; it does not empty in one.
## How this is checked ## How this is checked
@@ -0,0 +1,106 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md
---
# 201. A module keeps its current state in key-value buckets it declares, and reaches them through the runtime
## Context
A module's code reaches the bus through the node's runtime: it publishes events, subscribes to them
and asks tools ([ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)).
Events are kept for a week and replayed to a consumer that was away; requests are kept nowhere. What
neither gives is **the current value of something**, seen by every machine, including one that joins
after it was written. The first module to need it — the operator's agent on a machine — registers MCP
servers for every machine as events, and a machine assigned later never hears of them; and it would
replay a week of licence rotations where it needs only the binding that holds now. Research
[024](../01-RESEARCH/024-state-a-module-keeps-on-the-bus/00-overview.md) measured the alternatives and
the grants against a real server.
[Design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 already names *state* as one of the
mesh's relationships — 1:1, last per subject — and reserves it to the mesh's own declarations.
[Design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 expects key-value buckets on the bus.
## Considered Options
1. **Key-value buckets a module declares, created by the controller, reached through the runtime.**
Chosen.
2. **State as events on EVENTS, read last-per-subject.** Rejected: retention is per stream and EVENTS
keeps seven days, so a value unchanged for a week disappears; a second stream over the same subjects
is refused by the server (design 32 §3). And events give no get, list or delete.
3. **A last-per-subject stream per module, written by hand.** Rejected: it is what a key-value bucket
is on the server, without the client's get, list, delete and watch — the mesh writing NATS's
key-value layer again.
4. **State in a module's own files or database, shared by asking a tool.** Rejected for state every
machine must see: a machine joining later has to know whom to ask and poll, and an owner that is
down answers nothing — the property the bus exists to remove.
## Decision
**1. A module declares its state by name.** `state` names the buckets it owns, by local name; every
instance of the module may write and read them. `reads` names another module's bucket as
`<module>.<name>`, read-only. A bucket's options are its owner's: how many past values a key keeps,
and how long a value lives. A manifest names no bucket, stream or subject (design 32 §1).
**2. One bucket per module per name, mesh-wide.** A key may name a machine by the module's own
convention; the mesh does not scope buckets per machine.
**3. The controller creates the buckets, from the catalogue, on every raise** — from registration,
like a seat's stream, so a reader can watch a bucket whose owner is not yet assigned anywhere. A module
never creates one. The runtime's grant on each bucket is the union of what its carried modules may do:
an owner's instances write and read, a reader's read.
**4. Each assignment is issued its buckets in its membership** ([ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)),
by the name the module uses for each and whether it may write. The runtime serves `mesh/state.get`,
`put`, `delete`, `keys` and `watch` on the bundle's channel from that list, and refuses — with the
reason — a bucket the module was not issued and a write to one it only reads. A watch delivers the
current values first, without deletions, then an end-of-current marker, then every change, each as a
`mesh/state` request the bundle answers.
**5. No secret is stored in a bucket, sealed or not.** A bucket is a stream, and design 32 §10 keeps
every secret off streams. A value that needs a secret names it; the secret travels on request/reply.
**6. The mesh caps size; a bucket outlives its module.** One value per key and no expiry unless the
owner says otherwise; at most 256 KiB a value and 64 MiB a bucket. Unassigning a module leaves its
buckets and what is in them ([ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)); a bucket
whose declaration is gone from the catalogue is reported, never removed by the mesh.
## Consequences
- A machine that joins reads the current state at once, and every machine sees a change as it
happens, with no consumer created per reader and nothing replayed.
- The runtime's channel has a sixth verb family, and the SDKs a small state surface over it — a
contract, which ADR 0039 admits: it changes when the verbs do, rarely, and every module should be
rebuilt when it does.
- What got harder: the runtime must keep each module to its own buckets, because one principal per
machine carries all of them and the server enforces only the union. A write the server refuses
surfaces to a client as a timeout, not a refusal, so the runtime's own refusal is what a module sees.
- The secrets rule is only partly mechanical. Sealed values cannot be recognised; the runtime refuses
a value with a field whose name says it is a credential, which catches the ordinary mistake and not a
determined one. For the operator's agent this means an MCP server's authorisation header stays out of
its bucket.
- Buckets accumulate as modules come and go; that they are reported rather than removed is the price
of not deleting data.
## How it is checked
| Rule | Checked by |
|---|---|
| A manifest's state names are local, and a read names a bucket its owner declares | the catalogue's registration check, per manifest; a catalogue test that every `reads` whose owner is present names a bucket that owner declares |
| Buckets exist for every declared state | the controller's raise asserts them idempotently; its test over a real bus |
| Owners write, readers only read | the composer's test of the grants, per principal kind; the runtime's refusal test over a real bus |
| A watch hands current values first, without deletions, then changes | the runtime's test over a real bus |
| No credential-named field in a value | the runtime's refusal test |
| Live | one module puts on one machine and another machine's watch sees it; a machine assigned afterwards reads it at start |
## References
- Research [024](../01-RESEARCH/024-state-a-module-keeps-on-the-bus/00-overview.md)
- [Design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 and §10, [design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 and §3
- [ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md), [ADR 0039](0039-what-the-sdk-holds-and-refuses.md),
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md),
[ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)
@@ -7,11 +7,14 @@ reconstructed: false
extends: 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md extends: 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
--- ---
# 201. A provider declares what it derives for each consumer, and the mesh tells both ends # 202. A provider declares what it derives for each consumer, and the mesh tells both ends
> Written as 0188 on 2026-10-02 and renumbered to 0201 on 2026-10-04: the record of the bundles > **Written as 0188 on 2026-10-02, renumbered to 0201, and to 0202 on 2026-10-04.** Twice, for the
> refactor took 0188 on main while this one waited in a pull request, and the mesh's own code now > same reason twice: the bundles refactor took 0188 while this waited in a pull request, and the
> cites that one. Only the number moved; the decision is the one taken on the 2nd. > key-value-buckets record took 0201 while this waited again. Both times the number was free when
> it was chosen and taken by the time this merged. Only the number moved; the decision is the one
> taken on the 2nd. The check that refuses two records sharing a number is what caught it, both
> times — a number is how a record is cited, and three repositories cite this one.
## Context ## Context
@@ -94,6 +97,15 @@ a literal in a consumer's definition is not merely redundant — it is the one t
disagree with what the provider will actually create. The three object-store consumers lose their disagree with what the provider will actually create. The three object-store consumers lose their
hand-written bucket names in this change. hand-written bucket names in this change.
**5. A consumer that keeps several holders of one provision may not be served a derived value.**
Each holder gets its own login, `…_<local>` ([ADR 0094](0094-a-module-may-hold-several-secrets-from-one-provider.md)),
and a provider derives from the login — so it would make one resource per holder, while the
consumer's side has one binding and one `${bound:<provision>:<key>}`, both derived from the
un-suffixed identity. That is this record's own failure one case to the side, and just as quiet:
the consumer would authenticate and be refused on every object. Refused at resolution, naming
both ends. Lifting it means giving the consumer's side a local dimension, which is a decision and
not an omission.
## Consequences ## Consequences
- One more thing a definition may say, and one less thing a module may be wrong about. The - One more thing a definition may say, and one less thing a module may be wrong about. The
@@ -105,6 +117,10 @@ hand-written bucket names in this change.
- A provider that already serves consumers keeps serving them: the derived value equals what the - A provider that already serves consumers keeps serving them: the derived value equals what the
code derived, so no bucket, database or login changes name. This is a change of **who says it**, code derived, so no bucket, database or login changes name. This is a change of **who says it**,
not of **what is said**. not of **what is said**.
- A refusal here fails **that machine's push**, naming the definition, and nothing else. That is
deliberate and is the opposite of a module quietly left out: a definition that transcribes
somebody else's rule is wrong everywhere, not just here, and the loud failure is in front of
whoever can fix it.
- The mesh now holds a rule in another system's alphabet — one rule, `dns`, stated once. A second - The mesh now holds a rule in another system's alphabet — one rule, `dns`, stated once. A second
alphabet is a decision, not an addition: the cost of each is that the mesh must be right about alphabet is a decision, not an addition: the cost of each is that the mesh must be right about
somebody else's naming, and that cost is only worth paying where the mesh already mints the name. somebody else's naming, and that cost is only worth paying where the mesh already mints the name.
@@ -118,6 +134,8 @@ hand-written bucket names in this change.
consumer — one test asserting the three agree, because agreeing is the whole point. consumer — one test asserting the three agree, because agreeing is the whole point.
- Two consumers of one provider on one machine get two different derived values, and neither gets - Two consumers of one provider on one machine get two different derived values, and neither gets
the other's. the other's.
- A consumer with several holders of a deriving provider is refused, with both ends named — the
test asserts the refusal, not merely that something failed.
- A catalogue-wide test refuses a consumer definition that writes a literal where its provider - A catalogue-wide test refuses a consumer definition that writes a literal where its provider
derives: the provider's `serves` names the key, so the catalogue can say which definitions derives: the provider's `serves` names the key, so the catalogue can say which definitions
transcribe one. transcribe one.
@@ -0,0 +1,131 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
---
# 203. The account's environment is one module's, and every module contributes to it
## Context
A variable or a `PATH` entry is a fact about the operator's account. A toolchain needs its directory
on `PATH`, a version manager needs a variable naming its directory, an agent needs a variable that
turns one of its behaviours off, and the shell sets an editor. Today every one of these is a line of
one shell's syntax in one hand-written startup file. [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
measured on four machines:
- about a quarter of the 65 lines a workstation runs at shell start are environment;
- written into `.zshrc`, that environment reaches only interactive zsh. It misses the login shell's
`execute` verb ([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)),
every script, and every program a graphical session starts;
- the service manager's place for the account's environment, `~/.config/environment.d/`, holds
nothing on any machine.
The shell module as first written carried some of these lines in its own block and dropped the rest.
## Considered Options
1. **Each module writes its own lines into the shell's startup file.** Rejected: one shell's syntax,
read by one kind of start, and the same lines rewritten by every shell module.
2. **Contribute the facts to the login shell, whose holder renders them.** Rejected: the environment
then depends on which module holds the shell, every shell module renders the same facts again, and
the graphical session sees nothing.
3. **Option 2, and the service manager's holder renders the same facts a second time** into
`environment.d`. Rejected: one fact set with two owners, whose renderings can disagree, and a
duty for the service manager unrelated to managing services.
4. **One file in `environment.d` syntax, sourced by shells.** Rejected: that syntax is close to
POSIX assignment but not equal, and a value one reader accepts breaks the other.
5. **Shells read the service manager's environment generator.** Rejected: every shell start then
runs a process and depends on the service manager, and the output is unquoted.
6. **A module of its own holds the environment.** One mesh seat, held by one module per node,
whose files are the account's environment. Every module contributes facts to it, and those facts
are written in each reader's format. Chosen. It was the operator's proposal.
Within option 6, two ways to write the files:
- **a. The holder's own code renders what it receives.** This was research 025's starting position.
Rejected: the code needs something to run it whenever a contribution changes, and the result exists
only after a machine has applied and run it.
- **b. The controller renders the facts into the holder's files,** in two named formats, at
composition. Chosen. The result is in the declaration before any machine applies it, nothing has to
trigger anything, and the two formats are standards: POSIX shell assignment and the service
manager's `environment.d`. The controller learns no shell. It writes an assignment in a standard
syntax, as it already writes a fail2ban stanza a module supplied.
## Decision
**1. The environment is a node seat, `node-environment`, in the mesh's own set.** One module per node
holds it, and it is the only writer of the account's environment. The first holder is a module of its
own (working name `node-env`), with no package and no process.
**2. Any module contributes to it with `environment`:**
- **`variables`:** names and values. A name is a POSIX variable name and never `PATH`. A value is
literal; it may use `${machine:…}`, resolved first, and may not contain `$`, a quote, a backslash or
a line break. The only expansion is the mesh's own, so the two formats cannot read one value
differently.
- **`path`:** entries, each placed at the `start` or the `end` of the account's `PATH`.
**3. The holder places the rendered environment with two placeholders** in its own files:
- **`${environment:posix}`** renders lines a POSIX shell sources:
- every variable exported;
- every `PATH` entry added only if missing, so sourcing twice changes nothing.
- **`${environment:systemd}`** renders the same facts as the service manager's user environment, with
the account's existing `PATH` kept between the start and the end entries.
Each rendered line names the module that contributed it, so the file answers *where did this come
from*. Contributions are ordered by module name, and then in the order a module declared them.
**4. The seat's protocol fixes where the POSIX file is:** `~/.config/mesh/environment.sh` under the
account's home. A shell sources that path without knowing which module wrote it. The service
manager's file is `~/.config/environment.d/50-mesh.conf`.
**5. Refused at composition:**
- two modules on one node setting the same variable, both named;
- an environment placeholder in a module that does not claim `node-environment`.
A node with contributions and no holder writes them nowhere. The holder's absence is visible in the
node's assignments, and no contributor is refused for it, because a missing `PATH` entry is a gap, not
a broken machine.
## Consequences
- A shell's part in the environment is one line in its always-read startup file, sourcing the
POSIX file. A second shell module writes the same line in its own syntax, and no contributor
changes when the login shell does.
- The graphical session sees the same `PATH` as the terminal, from the same facts.
- The controller gains one gathered field and two renderers. Both are tested byte for byte, like
the jails a node composes ([to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)).
- What a person sets for themselves stays theirs: variables of their own sit in their own lines of
their shell's file, read after the mesh's.
- **What got harder:** a value that needs another variable expanded (`$HOME`, `$XDG_CONFIG_HOME`)
must be written with the mesh's own `${machine:…}` facts, or it is refused. Expansion at shell start
is exactly what made one value mean two things in two readers.
- Once [issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)
closes, a value a person varies becomes a setting of the module that contributes it
([ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
## How it is checked
| Rule | Checked by |
|---|---|
| Both renderings, byte for byte, from a fixed set of contributions | the controller's environment tests |
| Sourcing the POSIX rendering twice leaves `PATH` unchanged | the same tests, running `sh` over the rendering |
| A variable set by two modules is refused, naming both | the controller's resolve test |
| An environment placeholder outside the holder is refused | the catalogue check, which registration runs |
| A value with `$`, a quote, a backslash or a line break is refused | the manifest's parse test |
| The zsh holder sources the path the seat fixes | the catalogue's zsh test |
## References
- [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
[ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md),
[ADR 0177](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md),
[ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
@@ -0,0 +1,131 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
---
# 204. A module contributes shell code to the login shell in named slots, and the login shell is the mesh's seat
## Context
Some of what a shell runs at start is code in that shell's own syntax, and it belongs to other
modules:
- a prompt theme loads itself and its configuration;
- plugins load themselves;
- a version manager sources its loader.
Order matters: a prompt's instant-prompt cache must run first, and syntax highlighting last. The
predecessor kept all of this in one file per machine, and installed the theme and plugins by cloning
them in a hook.
[Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md) measured that file
as byte-identical on four machines. It carries:
- the shell's defaults;
- code belonging to four other pieces of software;
- a handful of the operator's own lines.
Nothing gave the other pieces a way in.
Two further facts bear on the seat itself:
- **The seat is declared by the zsh module** ([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
§1, under [ADR 0126](0126-a-module-declares-its-own-seats.md)). The controller refuses a second
module declaring a seat name, so fish or bash could only ever claim it, and the seat exists only
while zsh's definition is registered.
- **The kept region is the other way round.** [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
describes a kept region as a marked block holding the operator's lines. The host built the inverse:
the mesh's region is the marked block, and every byte outside it is kept, verified unchanged, and
given back when the module goes.
## Considered Options
1. **A drop-in directory** that each module places a file in, and the shell sources. Rejected: every
contributor names a path inside the shell module's territory
([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)); order becomes a naming
convention nothing checks; and nothing ties the file to the shell actually being the one it is
written for.
2. **Facts the holder renders**, through `contributes` / `receives`. Rejected: code is not a fact,
and the holder would only paste it.
3. **Code contributed for a named shell in a named slot, assembled by the controller into the
holder's file.** This is what the controller already does for fail2ban jails: each module supplies
text in the tool's own format, and the controller sorts and concatenates it into the holder's file
without interpreting it. Chosen.
For order, numbers (`10`, `50`, `90`) were rejected: every contributor guesses one, and collisions are
silent. **Three named slots** were chosen: `first`, `normal`, `last`.
## Decision
**1. `node-login-shell` is a node seat in the mesh's own set,** with the verb `execute`. It replaces
the module-declared `login-shell`. Everything else ADR 0176 decided stands: the holder sets the
account's login shell through the `user` shape, `execute` is the contract, and any node may call it.
A shell module claims the seat; none declares it.
**2. Any module contributes shell code with `shell`:** entries naming the shell they are for (`zsh`,
`bash`, `fish`), the slot, and the code. The controller does not read the code.
**3. The holder places the code with placeholders** in its own files: `${shell:<shell>:<slot>}`. Each
is filled with that shell's code for that slot, from every module on the node:
- ordered by module name;
- each piece preceded by a line naming its module;
- empty when nothing is contributed.
A shell-code placeholder in a module that does not claim `node-login-shell` is refused.
**4. The holder's duties, which are the seat's protocol:**
- Source the account's environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md))
from the startup file every start of that shell reads. For zsh that is `.zshenv`, which a script, a
login and `execute` all read.
- Write its interactive block at the **start** of the interactive startup file, so the operator's own
lines run after the mesh's and win.
- Run `execute` as a non-interactive login shell in the account's home:
- bounded below the runtime's call limit;
- its output bounded;
- its whole process group ended on timeout.
**5. The marked block is the mesh's; everything outside it is the operator's.** This is how ADR 0174's
"kept region" is built. That record keeps its decision and gains a note saying where the mechanism
lives.
## Consequences
- A prompt, a plugin and a version manager are each a module with its own package or archive, its own
configuration file, and a contribution. Assigning one adds its line to the shell, and unassigning it
takes the line away at the next composition.
- Assigning the shell module loses nothing the machine does today:
- what is common to every machine becomes the shell module's default or another module's
contribution;
- what is the operator's stays below the block.
- **The one-off migration is a person's act**, listed in the shell module's documentation
([ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)):
delete the lines the block now carries from the found file.
- `login-shell.execute` becomes `node-login-shell.execute`. Nothing has called it yet; the shell
module was never assigned.
- **What got harder:** a module wanting a line in the shell must say which shell and which slot, and a
module supporting three shells writes its code three times. That is the honest cost of code in
three syntaxes.
## How it is checked
| Rule | Checked by |
|---|---|
| Code lands in its slot, in module order, only for its shell | the controller's shell-contribution tests |
| A shell-code placeholder outside the holder is refused | the catalogue check |
| `node-login-shell` is the mesh's, and no module may declare it | the seat table's tests |
| The zsh block sits at the start, sources the environment from `.zshenv`, and holds the three slots | the catalogue's zsh test |
| `execute` is bounded in time and output and kills its process group | the zsh module's tool tests over real child processes |
## References
- [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
[ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md),
[ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
[to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
@@ -0,0 +1,69 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
---
# 205. Software the distribution does not package ships as a pinned archive of the module's own
## Context
The prompt theme the operator uses is not in the distribution's repositories. Its two plugins and an
autocomplete plugin are. The predecessor installed all four by running `git clone` against their
upstream repositories from an install hook. That way:
- the version on a machine was whatever upstream's default branch held the day the hook ran;
- two machines set up a week apart could differ;
- a machine with no route to upstream failed its install.
The mesh already has a pinned, delivered form for a module's own files: an **archive artifact** built
from a directory of the module's source, delivered by the artifact store, unpacked by the host's
`archive` resource, and pinned by digest. One showcase module uses it.
## Considered Options
1. **Clone from upstream on the machine,** as the predecessor did. Rejected: unpinned, unreproducible,
and it needs upstream reachable from every machine.
2. **Build from the distribution's user repository.** Rejected: the host installs packages from the
distribution's own repositories. A user-repository build is a toolchain on every machine for one
theme.
3. **Vendor a pinned upstream release into the module's directory and ship it as the module's archive
artifact.** Chosen. The release and its version are named in the module, its licence travels with
it, and every machine gets the same bytes from the mesh's own store.
## Decision
**A module whose software the distribution does not package carries a pinned upstream release in its
own source directory and ships it as an archive artifact.**
- The module's documentation names the upstream, the version and the licence.
- The host unpacks it with the `archive` resource into a directory the module owns.
- An upgrade is a change to the module, reviewed like any other.
Software the distribution *does* package is installed as a package; a vendored copy of it is
refused in review.
## Consequences
- The catalogue grows by the size of what it vendors: 1.4 MB for the prompt theme at the pinned
release.
- Upstream's security fixes reach a machine only when somebody updates the module. That is the same
trade every pinned dependency makes, and it is visible: the version is in the module.
- **What got harder:** a vendored program that downloads more at run time, as the prompt theme does
for its git status helper, still fetches that part from upstream on first use. This record pins
what the mesh ships, not what the software fetches for itself. The module's documentation says so.
## How it is checked
| Rule | Checked by |
|---|---|
| The archive is pinned by digest | the host's declaration validation, which refuses an archive without one |
| The upstream, version and licence are named | review of the module's documentation; the module's test asserts the licence file is in the archive |
| Packaged software is not vendored | review |
## References
- [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
@@ -0,0 +1,144 @@
---
topic: what runs on it
status: accepted
date: 2026-10-04
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
---
# 206. A node reports the Anthropic grant it holds; the licence manager adopts a licence by refreshing it, and what each node should hold is the manager's state
## Context
[ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
made the licence manager a module holding the `anthropic-licence-manager` seat: one rotation source, the
long-lived grants in its own store, a short-lived token handed to a node sealed on request/reply, the
agent module alone writing what the agent reads. How the manager *learns* a licence, and who starts each
exchange, it left to a later shape, and three texts have since disagreed: ADR 0183 has a node register
its key and the manager adopt a login only into a licence the node is already bound to; its dated note
of 2026-10-03 has the manager start every exchange and visit every node on a schedule; the agent module
as built asks the seat for its token when an event says to, and pushes a login to the seat.
**The operator settled it on 2026-10-04, in the operator's own words:** the manager must hold the active refresh token;
whichever node a login happened on holds the latest one; every client publishes what its credentials
file holds, the manager sees a licence it does not own yet and takes it into its store, and from then on
rotates it and distributes the access token. A manager launched for the first time holds no licence and
accepts what the clients report. Several nodes report the same account — today the nodes are all logged in
to one personal account — and before the manager adopts a grant it must know the refresh token still
works.
Two facts bound how that is built:
- **A refresh token cannot be published.** Anything published on the bus is kept, and a secret never
enters a stream, sealed or not ([design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10).
A module's state is a stream too, and the runtime refuses a value carrying a field named like a
credential ([ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md);
refused live on 2026-10-04 for an `Authorization` header).
- **A refresh token can only be checked by using it.** No endpoint answers "is this refresh token
valid" without exchanging it, and an exchange is presumed to rotate it (ADR 0183: the predecessor
lost a licence to a reused one). Checking and adopting are therefore one act, and whoever checks
becomes the token's only live holder.
Since ADR 0201 the bus has the shape this needs: **state** every node sees, including one that joins
later or a manager that starts later, read whole on start and then watched.
## Considered Options
1. **Each node publishes its credentials file, the token included.** What the operator described,
literally. Rejected for the token only: it would sit in a stream every principal that reads the
bucket can read, for as long as the bucket keeps it, and the runtime refuses it anyway.
2. **The manager visits every node on a schedule and collects a waiting login** (ADR 0183's dated
note). Rejected: the manager must know every node in advance and poll it, a node that joins later
waits for the next visit, and "what does each node hold" lives nowhere anyone can read.
3. **Each node reports what it holds as state, without the secret; the manager asks for the secret
only when the report shows a grant it does not hold, and adopts by refreshing.** Chosen: the
operator's flow, with the one part that cannot be on the bus moved onto request/reply.
## Decision
**1. Every agent module reports what its node holds, as its own state.** One key per node in the
module's `holdings` state: the account's identity as the agent's own state file names it (account id,
address, organisation), the kind, the refresh token's **fingerprint** and whether one is present at
all, the access token's fingerprint and expiry, the licence it was last handed, and when the credentials
file last changed. Written when the module starts — a node already logged in when the module is first
assigned reports at once — and again whenever the credentials file changes. **No token, ever**: a
fingerprint names a token without being one.
**2. A licence is an account, and the manager learns it from the reports.** The manager reads every
node's `holdings` at start and watches them. A report carrying a refresh token whose fingerprint the
manager does not hold is a **candidate**: for an account it has no licence for yet, a new licence; for
one it has, a login made since. A manager launched for the first time holds no licence and treats
every report as a candidate. An API key still enters only through the seat's `adopt` verb, from a file
on the manager's node.
**3. The secret travels only when asked for.** For a candidate, the manager calls that node's agent
module on request/reply, giving its own public key, and is answered with the grant sealed to that key
(ADR 0183's channel, unchanged).
**4. Adopting is refreshing.** The manager exchanges the candidate's refresh token at the vendor's
endpoint under its lease for that account. If the exchange succeeds, the grant it got back is the
licence's, stored encrypted, and the manager is from then on its only rotation source. If it fails, the
candidate is recorded dead, nothing is adopted, and the report says so. **Several nodes, one account:**
candidates for one account are tried newest login first; the first that refreshes is adopted, and the
manager does not exchange the others.
**5. A node holds an access token only, so the latest login wins.** A node bound to an adopted licence
is handed the access token and nothing else, and the agent module writes the credentials file without a
refresh token — so the agent on the node can never refresh it, and two refreshers never hold one grant.
A refresh token appearing in a node's file afterwards can therefore only be a person's login there; its
report makes it a candidate, and if it refreshes it replaces the licence's grant. That is the operator's
"whichever node a login happened on holds the latest one", made mechanical.
**6. What each consumer should hold is the manager's state.** One key per consumer in the manager's
`bindings` state: the licence, its kind, and a **generation** that increases with every rotation and
every switch. The agent module watches its own key; when the generation is newer than the one it
applied, it asks the seat's `current` verb for the token, sending its public key, and is answered sealed
(request/reply). A node that was away reads its key when it is back and asks once. The `licence.rotated`
and `licence.switched` events go: what they announced is now the state itself, and a node needs the
latest, not the history.
**7. A first binding follows the login.** When the manager adopts a licence from a node's report, a
node with no binding yet whose report names that account is bound to it. Every later change is a
person's act through `bind`, `switch` and `release`, as ADR 0183 says.
**8. The identity guard stands, on two sources.** The account a grant is filed under is the identity
the node read from the agent's own state. Where the vendor's answer to the refresh names the account,
the manager compares the two and refuses a mismatch with a notification; whether it names it is
measured when the manager is built, and the record of which source decided is kept in the audit.
## Consequences
- The manager needs no configuration to start: launched on a mesh whose nodes are logged in, it adopts
every account they hold, one licence each, from the newest login that still refreshes.
- Every node's holding is readable by anyone allowed to read the state — the console, an agent, the
operator — without a token in sight, which is what `licence_status` on each node answered one at a
time.
- **What got harder:** adoption consumes the refresh token the node held. On a node whose grant was
adopted, the agent's own copy is dead from that moment; until the manager hands it an access token
(decision 6), the agent keeps the access token it already had, which lives hours. And a node whose
file still holds a refresh token after adoption — it was not handed one yet — is a second holder of a
dead grant, not a live one, so the rotation-source rule holds.
- A candidate whose refresh fails is not retried by the manager: a dead refresh token does not come
back. A person logs in again, and the new report is a new candidate.
- Nothing in the reports is secret, but they do say which account each node uses; readers of the state
are declared in manifests like any other.
## How it is checked
| Rule | Checked by |
|---|---|
| No report carries a token | the runtime refuses a credential-named field (ADR 0201's test); the agent module's test: a report built from a full credentials file holds fingerprints and identity only |
| A node already logged in reports at start | the agent module's test: with a credentials file present and unchanged, starting writes its `holdings` key |
| A candidate is adopted only by a successful refresh, newest login first, once per account | the manager's tests against a stub vendor: two reports for one account, the newer refreshes and is adopted, the older is never exchanged; a failing refresh adopts nothing and records the candidate dead |
| A node is handed an access token only | the agent module's test: the file it writes after a hand-over holds no refresh token |
| A newer generation is fetched once, by request | the agent module's test: a `bindings` change with a newer generation asks `current` once; an equal one asks nothing |
| No event carries a token, and none announces a rotation any more | the manager's test of everything it publishes |
| Live | the manager launched with no licence on a mesh whose four nodes are logged in to one account adopts one licence, binds the four nodes, and each node's file then holds an access token and no refresh token |
## References
- [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the manager, its seat and its channel, which this extends
- [ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md) — module state, and the refusal of a secret in it
- [design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10 — no secret in a stream
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md), [to-be 39](../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md) — the two modules, amended by this record
+6 -1
View File
@@ -190,7 +190,7 @@ python3 00-META/checks/index.py fail if stale
- **0187** — [A dead tracker is not the machine's failure](0187-a-dead-tracker-is-not-the-machines-failure.md) - **0187** — [A dead tracker is not the machine's failure](0187-a-dead-tracker-is-not-the-machines-failure.md)
- **0189** — [The store keeps what the records name, and a maintenance step holds its writers still](0189-the-store-keeps-what-the-records-name.md) - **0189** — [The store keeps what the records name, and a maintenance step holds its writers still](0189-the-store-keeps-what-the-records-name.md)
- **0190** — [A seat's work is shared by its holders, and building is the first such role](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md) - **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)
- **0201** — [A provider declares what it derives for each consumer, and the mesh tells both ends](0201-a-provider-declares-what-it-derives-for-each-consumer.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)
### Its tiers, from the bottom up ### Its tiers, from the bottom up
@@ -300,6 +300,11 @@ python3 00-META/checks/index.py fail if stale
- **0195** — [The mesh's tools are found by address, not announced whole](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md) - **0195** — [The mesh's tools are found by address, not announced whole](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)
- **0197** — [Every tool announces itself on the bus, in the NATS services protocol](0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md) - **0197** — [Every tool announces itself on the bus, in the NATS services protocol](0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)
- **0198** — [A module's long-running code is launched by the node's runtime, and reaches the bus through it](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md) - **0198** — [A module's long-running code is launched by the node's runtime, and reaches the bus through it](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)
- **0201** — [A module keeps its current state in key-value buckets it declares, and reaches them through the runtime](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)
- **0203** — [The account's environment is one module's, and every module contributes to it](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)
- **0204** — [A module contributes shell code to the login shell in named slots, and the login shell is the mesh's seat](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
- **0205** — [Software the distribution does not package ships as a pinned archive of the module's own](0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md)
- **0206** — [A node reports the Anthropic grant it holds; the licence manager adopts a licence by refreshing it, and what each node should hold is the manager's state](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)
### How it is built ### How it is built
+23 -2
View File
@@ -7,8 +7,10 @@ code:
- mesh-tools src/broker-amqp.ts (to be replaced) - mesh-tools src/broker-amqp.ts (to be replaced)
- mesh-catalog modules/nats (to be written) - mesh-catalog modules/nats (to be written)
- mesh-sdk src (the protocol's NATS binding, step 3) - mesh-sdk src (the protocol's NATS binding, step 3)
updated: 2026-10-02 - mesh-tools node-tools/internal/bus (a module's state, ADR 0201)
updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
- 02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md - 02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md - 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
- 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md - 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
@@ -48,6 +50,7 @@ mesh's own state lives, and where what a module may say is decided by what it de
| **events** — a module says something happened | 1:many | delivered to every consumer that declared it; dead-lettered when it cannot be | | **events** — a module says something happened | 1:many | delivered to every consumer that declared it; dead-lettered when it cannot be |
| **tools** — a module or a person asks another's tool | request/reply | one answer, from one server, or a timeout | | **tools** — a module or a person asks another's tool | request/reply | one answer, from one server, or a timeout |
| **work to a role** — a module submits to a capability without knowing who provides it | job | exactly one holder does it; it queues while nobody does | | **work to a role** — a module submits to a capability without knowing who provides it | job | exactly one holder does it; it queues while nobody does |
| **state** — a module's current value of something, every machine reading it | key-value | the newest per key, kept until replaced or deleted; read whole by a machine that joins later |
The last two rows are the ones worth dwelling on, because they are not messaging in the sense of The last two rows are the ones worth dwelling on, because they are not messaging in the sense of
carrying bytes from A to B. **A role is addressable**, so a caller names the capability and never carrying bytes from A to B. **A role is addressable**, so a caller names the capability and never
@@ -56,7 +59,8 @@ changing ([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)
property of the mesh's architecture that happens to be expressed in subjects. property of the mesh's architecture that happens to be expressed in subjects.
And more of the mesh lands here as it is built: conditions and observed state in key-value And more of the mesh lands here as it is built: conditions and observed state in key-value
buckets that anything may watch, the server's own advisories becoming observations like any other buckets that anything may watch — the first of them a module's own declared state, *2026-10-04*
([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)) — the server's own advisories becoming observations like any other
([research 017](../../01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md)), and a person's ([research 017](../../01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md)), and a person's
client speaking the bus directly rather than through a surface built over it (§7). None of that client speaking the bus directly rather than through a surface built over it (§7). None of that
is a message being moved; all of it is the bus being the mesh's centre. is a message being moved; all of it is the bus being the mesh's centre.
@@ -84,8 +88,16 @@ mesh.seat.<seat>.event.<verb> a role's own event (JetStream: EVENT
mesh.seat.<seat>.tool.<verb> a role's tool (core request/reply) mesh.seat.<seat>.tool.<verb> a role's tool (core request/reply)
mesh.ask.<node>.<command> the controller's command api (core request/reply) mesh.ask.<node>.<command> the controller's command api (core request/reply)
mesh.assignment.<node>.<module> an assignment's membership (JetStream: ASSIGNMENTS, last-per-subject) mesh.assignment.<node>.<module> an assignment's membership (JetStream: ASSIGNMENTS, last-per-subject)
$KV.<module>_<name>.<key> a module's state (JetStream: a key-value bucket per declared name)
``` ```
**Added 2026-10-04** ([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)):
the last row is outside `mesh.` on purpose. A key-value bucket is NATS's own construct and lives
under NATS's own prefix, which is what lets the server's key-value layer — direct reads, rollups,
delete markers, watches — do the work instead of the mesh writing it again. The bucket is named for
the module and the local name joined by an underscore, which neither may contain, so two modules can
never derive one bucket.
**Revised 2026-10-01** ([ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)): the rows above **Revised 2026-10-01** ([ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)): the rows above
for a module's and a seat's tools are the shapes the controller *issues*, not rules a runtime carries. for a module's and a seat's tools are the shapes the controller *issues*, not rules a runtime carries.
Every assignment is published a membership — what it serves and where, in which queue, its seat verbs, Every assignment is published a membership — what it serves and where, in which queue, its seat verbs,
@@ -155,6 +167,7 @@ Core NATS is at-most-once. Everything the mesh must not lose lives in a JetStrea
| CONTROL | `mesh.control.>` except `alive` (a build's outcome moved to its seat, ADR 0121) | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped | | CONTROL | `mesh.control.>` except `alive` (a build's outcome moved to its seat, ADR 0121) | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped |
| NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest | | NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest |
| EVENTS | `mesh.mod.*.event.>` and `mesh.seat.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream). *2026-10-01:* a build's whole log is here too, as the build-machine seat's `log.<build id>` events ([ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)) — one subject per build, a week of retention, read back by `builds --log <id>` with a consumer that is gone when the reading is done | | EVENTS | `mesh.mod.*.event.>` and `mesh.seat.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream). *2026-10-01:* a build's whole log is here too, as the build-machine seat's `log.<build id>` events ([ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)) — one subject per build, a week of retention, read back by `builds --log <id>` with a consumer that is gone when the reading is done |
| `KV_<module>_<name>` | `$KV.<module>_<name>.>` | the newest value per key — as many past values as the owner declared — no age unless the owner declared one; a value at most 256 KiB, a bucket at most 64 MiB | a module's state (ADR 0201): one per name in a manifest's `state`, created from the catalogue on every raise, so it exists before its owner runs anywhere; kept when the module is unassigned, because what it holds is data |
Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool
call is a timeout the caller already handles. call is a timeout the caller already handles.
@@ -234,6 +247,14 @@ expresses this exactly, per subject, and better than a vhost could:
permissions for each consumed event's subject, its tool subjects, and that same inbox prefix. permissions for each consumed event's subject, its tool subjects, and that same inbox prefix.
Nothing else. A module that tries to publish outside its emits is refused by the server, not by Nothing else. A module that tries to publish outside its emits is refused by the server, not by
convention. convention.
- **A module's state** (ADR 0201), for whichever principal carries the module — today the machine's
runtime, whose grant is the union of its modules': binding to the bucket, reading a key directly,
and an ordered consumer for listing and watching, created and deleted on the bucket's own stream
and nothing else's; and, for the owner's instances only, publishing under the bucket's own
`$KV.<bucket>.>`. *Measured 2026-10-04 against a running server:* without the consumer-delete
grant a watch cannot be stopped cleanly, and a write the server refuses reaches the writer as a
timeout rather than a refusal — so the runtime refuses first, from the membership, and the grant
is the second line.
- **The controller's user** owns `mesh.control.>`, `mesh.node.>` and the streams, and may submit work - **The controller's user** owns `mesh.control.>`, `mesh.node.>` and the streams, and may submit work
to the seats the mesh's own flows use — a build, for one (ADR 0121). to the seats the mesh's own flows use — a build, for one (ADR 0121).
**A host's user** may publish its own `mesh.control.<node>.>` and subscribe its own **A host's user** may publish its own `mesh.control.<node>.>` and subscribe its own
@@ -14,7 +14,7 @@ decisions:
- 02-DECISIONS/0084-which-provider-serves-a-consumer.md - 02-DECISIONS/0084-which-provider-serves-a-consumer.md
- 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md - 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md
- 02-DECISIONS/0038-the-mesh-assigns-the-port.md - 02-DECISIONS/0038-the-mesh-assigns-the-port.md
- 02-DECISIONS/0201-a-provider-declares-what-it-derives-for-each-consumer.md - 02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md
--- ---
# 27 — A module requires, the mesh resolves # 27 — A module requires, the mesh resolves
@@ -208,7 +208,7 @@ the placeholder allows: the definition says which values reach which requirement
does. *How it is checked:* the unit tests named in issue 173, and the plan comparison that closed it. does. *How it is checked:* the unit tests named in issue 173, and the plan comparison that closed it.
*A provider says once what it derives for each consumer (2026-10-02, *A provider says once what it derives for each consumer (2026-10-02,
[ADR 0201](../../02-DECISIONS/0201-a-provider-declares-what-it-derives-for-each-consumer.md), [ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md),
[issue 124](../../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)):* [issue 124](../../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)):*
where a provider **names the resource** it gives each consumer — a bucket, a database, a vhost — the where a provider **names the resource** it gives each consumer — a bucket, a database, a vhost — the
name is derived per consumer, and a literal `serves` block could not carry it. A served value may name is derived per consumer, and a literal `serves` block could not carry it. A served value may
@@ -221,7 +221,7 @@ its binding's served facts and as `${bound:<provision>:<key>}` in any file it wr
as `derived` on that consumer's entry in its contributions file, so its provisioner is told the name as `derived` on that consumer's entry in its contributions file, so its provisioner is told the name
rather than recomputing it. A consumer that writes the derived value into its own definition instead rather than recomputing it. A consumer that writes the derived value into its own definition instead
of asking for it is refused, naming the placeholder to use. *How it is checked:* the unit tests in of asking for it is refused, naming the placeholder to use. *How it is checked:* the unit tests in
ADR 0201's "how this is checked", each run against the unchanged controller first. ADR 0202's "how this is checked", each run against the unchanged controller first.
## How a definition reads what was resolved ## How a definition reads what was resolved
@@ -11,8 +11,10 @@ code:
- mesh-host internal/apply/apply.go - mesh-host internal/apply/apply.go
- mesh-tools src/main.ts - mesh-tools src/main.ts
- mesh-catalog modules/mesh-catalog - mesh-catalog modules/mesh-catalog
updated: 2026-10-02 - mesh-tools node-tools/internal/runtime (a module's state, ADR 0201)
updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md - 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md - 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md - 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
@@ -67,6 +69,8 @@ the catalogue, and the mesh would have hundreds of copies of a decision it made
| `tools: status` | queue-group subscription on `mesh.mod.<module>.tool.status` | | `tools: status` | queue-group subscription on `mesh.mod.<module>.tool.status` |
| seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.accept.send` | | seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.accept.send` |
| `uses: telegram-sender` | publish on that seat's `accept` subjects, and nothing else | | `uses: telegram-sender` | publish on that seat's `accept` subjects, and nothing else |
| `state: servers` | a key-value bucket for the module, created by the controller; its instances write and read it |
| `reads: billing.orders` | read and watch billing's `orders` bucket, and nothing else of it |
**Wildcards, and they are the mesh's rather than a bus's.** *Added 2026-09-27, from **Wildcards, and they are the mesh's rather than a bus's.** *Added 2026-09-27, from
[issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md).* A [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md).* A
@@ -221,7 +225,7 @@ service" versus "one worker per machine".
| credential | sealed, per consumer | none | none | none | none | | credential | sealed, per consumer | none | none | none | none |
| reply | — | none | none, or an event later | a report | awaited | | reply | — | none | none, or an event later | a report | awaited |
| retention | — | age and size | work queue, explicit ack | **last per subject** | none | | retention | — | age and size | work queue, explicit ack | **last per subject** | none |
| declared | `provides`/`requires` | `emits`/`consumes` | seat `accepts` / `uses` | the mesh's own | `serves` | | declared | `provides`/`requires` | `emits`/`consumes` | seat `accepts` / `uses` | the mesh's own; a module's `state` / `reads` | `serves` |
**Job** is the one [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) had no room **Job** is the one [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) had no room
for. Its table has events at 1:many and provisions at 1:1; a module submitting work to a service for. Its table has events at 1:many and provisions at 1:1; a module submitting work to a service
@@ -236,6 +240,31 @@ last-per-subject retention, and a node that has seen sequence *n* refuses *n−1
That is the wire-level answer to That is the wire-level answer to
[issue 107](../../04-ISSUES/107-a-declaration-carries-no-order/00-report.md). [issue 107](../../04-ISSUES/107-a-declaration-carries-no-order/00-report.md).
**A module declares state too.** *Added 2026-10-04,
[ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md).*
State was the mesh's alone, and modules had the same need with nowhere to put it: an MCP server
registered for every machine, sent as an event, never reached a machine assigned afterwards — its
consumer did not exist yet when the event passed — and a licence binding sent as events replays a
week of rotations where only the latest matters. So a module names the state it **owns** with
`state`, and another module's it **reads** with `reads: <module>.<name>`. Each is a key-value bucket
the controller creates from the catalogue, mesh-wide, existing from registration so a reader can
watch before the owner runs anywhere ([design 25](25-the-bus-on-nats.md) §3). Every instance of the
owner writes; a reader reads and watches. A key may name a machine by the module's own convention;
the mesh keeps one bucket per name, not one per machine, because "every server, for every machine"
is then one list rather than a walk.
What a module sees is what it named. Its assignment's membership lists its buckets by those names,
with whether it may write, and the runtime answers `get`, `put`, `delete`, `keys` and `watch` for
them on the bundle's channel — refusing, with the reason, a name it was not issued or a write to a
bucket it only reads. A watch hands the current values first, then every change: a bundle that starts
late, or starts again, has the whole of the state before it has any of the news.
The owner says how many past values a key keeps and how long a value lives, as a seat says how long
its backlog survives (§3); the mesh caps a value's size and a bucket's. **A bucket outlives its
module's assignment** — what a module stored is data
([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md)) — and one whose
declaration is gone is reported, never removed by the mesh.
## 5. Seats ## 5. Seats
A module declares a seat with its protocol, and the mesh enforces one holder at its scope A module declares a seat with its protocol, and the mesh enforces one holder at its scope
@@ -454,6 +483,14 @@ sealing key leaks, that stream is an archive rather than a moment. So:
existing discipline — *fetched from it, not carried* — applied to the one payload where carrying existing discipline — *fetched from it, not carried* — applied to the one payload where carrying
it is worst. it is worst.
**A key-value bucket is a stream, so the same holds for it.** *Added 2026-10-04,
[ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md).*
No secret is put in a module's state, sealed or not: state is exactly what a machine joining a year
later reads in full. A value that needs a secret names it, and the secret travels on request/reply.
Sealed values are plain text to anything inspecting them, so this is checked only partly — the
runtime refuses a value carrying a field whose name says it is a credential, which catches the
ordinary mistake and not a determined one.
**The bootstrap, which is circular and has a precedent.** The vault makes every secret **The bootstrap, which is circular and has a precedent.** The vault makes every secret
([ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md)), including the bus's own ([ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md)), including the bus's own
passwords. The vault is a module, and a module needs a bus account, whose password the vault passwords. The vault is a module, and a module needs a bus account, whose password the vault
@@ -523,3 +560,9 @@ billing existing under that name.
on, and exactly those two are rebuilt. on, and exactly those two are rebuilt.
- **A stale declaration is refused.** A bed: replay sequence *n−1* after *n*, and the node refuses - **A stale declaration is refused.** A bed: replay sequence *n−1* after *n*, and the node refuses
it rather than applying it. it rather than applying it.
- **A module reaches only the state it declared.** The composer's test: an owner's runtime may write
its buckets, a reader's may only read, and nothing else is granted; the runtime's test over a real
bus: a name not issued and a reader's write are refused with the reason.
- **State is current at once.** The runtime's test over a real bus: a watch hands the current values
without deletions, then an end-of-current marker, then changes. Live: a machine assigned after a put
reads it at start.
@@ -2,8 +2,9 @@
layer: to-be layer: to-be
status: designed status: designed
code: [] code: []
updated: 2026-10-02 updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md - 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md - 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md - 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
@@ -54,8 +55,10 @@ instruction file and the manager's tools:
| the console's entry in the agent's user-scope state | the managed settings' tool-server key, from the console's provision (§4) | | the console's entry in the agent's user-scope state | the managed settings' tool-server key, from the console's provision (§4) |
**The home.** Under [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) **The home.** Under [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
every path under `~/.claude` is *found*, with one exception: the agent's credentials file, which the the module owns the directory `~/.claude` — that it exists, that the operator owns it, its mode,
module's own code writes for a subscription licence (§5). The person's memory, history, projects, local `0700` — and declares it, so the mesh refuses a second module owning it. Of what is inside, it owns only
the agent's credentials file, which its own code writes for a subscription licence (§5); every other path
is *found*. The person's memory, history, projects, local
settings, their own rules, skills and tool servers are never read or written by the mesh. **The six settings, their own rules, skills and tool servers are never read or written by the mesh. **The six
predecessor files are the operator's to remove, once, on each workstation**; the module's documentation predecessor files are the operator's to remove, once, on each workstation**; the module's documentation
lists them, and until they go the agent reads stale instructions beside the mesh's. lists them, and until they go the agent reads stale instructions beside the mesh's.
@@ -63,9 +66,12 @@ lists them, and until they go the agent reads stale instructions beside the mesh
## 2. What the module declares and what its code writes ## 2. What the module declares and what its code writes
**Declared, applied by the host:** the agent's package (§7); the module's state directory; a facts file **Declared, applied by the host:** the agent's package (§7); the module's state directory; a facts file
in that directory carrying the node's name, the operator account, the console's endpoint, the module's in that directory carrying the node's name and the console's endpoint, and a settings file carrying the
settings; the bus, the console's provision, and that it uses the `anthropic-licence-manager` seat. role and the extra tool servers, merged from the module's settings layers — the bundle is told the two
Nothing under the home, nothing under `/etc`. files' paths, because a bundle's words are paths and constants only (ADR 0192); the bus, the console's provision, and that it uses the `anthropic-licence-manager` seat.
Two directories, declared so the ownership check sees them: the agent's managed directory under
`/etc`, root's, and `~/.claude` under the operator's home, the operator's. No *file* resource under
either: what is in them is written by the module's code (§2 below) or is the person's.
**Written by the module's code**, from the facts file and the manager's hand-over, whenever either **Written by the module's code**, from the facts file and the manager's hand-over, whenever either
changes: changes:
@@ -111,17 +117,20 @@ the playbooks in the record.
## 4. The console ## 4. The console
The module tells the agent where the console is, and the port is the console's to say. **The console > **Revised 2026-10-03, building it.** The vendor's managed-settings key for tool servers refuses any
provides a node-scoped provision** — its MCP endpoint on loopback — serving the port the machine gave > URL that is not `https://`, including one on loopback, so it cannot carry the console. The module
it, and the module requires it. A requirement names what the consumer is coupled to > owns the vendor's **exclusive** managed tool-server file instead (operator's choice): the console as
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)); co-location > `mesh`, over HTTP on loopback, and every server in the module's `mcp_servers` setting — and no other.
resolves it; a machine without the console refuses the module by name. [To-be 34](34-the-console.md) is > A server added by hand, a project's own file and a plugin's servers stop loading; claude.ai's
amended in the same change; issue 192 (open) found the gap. > connectors are kept by a managed setting. A person's own servers move into the setting, for the mesh
> or for one node. Stdio straight to the bus, through the runtime's own client, was weighed and left
> for later: it needs a verb the delivered runtime does not have, and a session holding its own bus
> connection breaks on a credential rotation.
**Other tool servers** a person wants on every machine, or on one, are a declared setting of this module **Other tool servers** a person wants on every machine, or on one, are a declared setting of this module
— mesh layer or node layer — rendered into the same managed key. A module tool, `mcp_configure`, — mesh layer or node layer — rendered into the same managed file. The person sets them with the
validates a server and sets the setting through the controller's settings verb, so the list stays controller's `settings` verb on this module, so the list stays declared state; the list is the operator's
declared state. The agent's own HTTP-only constraint for managed servers applies; a person's local choice, set where every setting is set. The agent's own HTTP-only constraint for managed servers applies; a person's local
command-based servers stay their own, in their own file. command-based servers stay their own, in their own file.
**The entry's name is `mesh`.** The hand-made entry both workstations carry today is named after this **The entry's name is `mesh`.** The hand-made entry both workstations carry today is named after this
@@ -131,36 +140,42 @@ it is the person's to remove, and until then the agent sees the mesh's tools twi
## 5. The licence: the consumer side ## 5. The licence: the consumer side
[ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) [ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
decides it; to-be 39 is the manager's half. This module: decides it and [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md) says how it moves; to-be 39 is the manager's half. This module:
- **makes a keypair** in its state the first time it runs and registers the public half with the seat; - **makes a keypair** in its state the first time it runs, and sends the public half with every request
- **serves `apply`**: the manager's hand-over, a token sealed to the module's key, with the licence's that is answered sealed;
name and kind. A rotation of the same licence is applied only if newer within one lineage; a switch is - **reports what the node holds**, as its own `holdings` state, one key for this node: the account's
applied regardless, because across licences the expiries are unrelated. The answer says applied or identity read from the agent's state file, the kind, the refresh token's fingerprint and whether one is
refused and why, and never echoes a token; present, the access token's fingerprint and expiry, the licence and generation it last applied, when the
- **pulls** at start and when its token nears expiry, by the seat's `current` verb, and keeps the last credentials file last changed. Written at start — a node already logged in reports at once — and on every
token when the manager does not answer, saying so; change of the file. Never a token: the runtime refuses one anyway;
- **writes** for a subscription licence the credentials file as the operator, access-token-only; for the - **hands over a grant only when asked**: `claude_code_grant` answers the manager, which gives its public
API-key licence sets the key-helper in the managed settings to a small program that prints the key key, with the full grant in the credentials file sealed to that key — the one time a refresh token
from the module's state, so no file under the home is touched; leaves the node, for the manager to adopt by refreshing it;
- **offers a login to the manager**: when the credentials file changes by a person's login, it reads the - **watches the manager's `bindings` state** for this node, and when the generation is newer than the one
account's identity from the agent's state file and offers the grant to the seat, sealed to the manager's it applied, asks the seat's `current` verb for the token, sealed to its own key. A rotation of the same
key, for adoption; the manager decides; licence is applied only if newer within one lineage; a switch is applied regardless;
- **serves `licence_status`**: which licence and kind this node holds, when the token expires, whether - **writes** for a subscription licence the credentials file as the operator, **access-token-only** — so
the agent here never refreshes, and a refresh token appearing later is a person's login, reported like
any change; for the API-key licence sets the key-helper in the managed settings to a small program that
prints the key from the module's state, so no file under the home is touched;
- **serves `claude_code_status`**: which licence and kind this node holds, when the token expires, whether
the file matches what was handed over — by fingerprint, never by value. the file matches what was handed over — by fingerprint, never by value.
Switching is the seat's `switch` verb, asked through the console; this module only applies what it is Switching is the seat's `switch` verb, asked through the console; this module only applies what the
handed. state says it should hold. *2026-10-04:* this replaces the manager's visits of 2026-10-03 (ADR 0183's dated
note): the node reports, the manager asks for a secret only when a report shows one it does not hold, and
a token is fetched by request when the state says it changed.
## 6. Scope, settings and the order of assignment ## 6. Scope, settings and the order of assignment
**Every node with an operator account** ([ADR 0181](../../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)). **Every node with an operator account** ([ADR 0181](../../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
None has one today; the operator states them first. **Per node:** the role. **Per mesh or per node:** All four nodes carry one since 2026-10-03. **Per node:** the role. **Per mesh or per node:**
extra tool servers. **Prerequisite:** the manager holds its seat and has adopted the licences. extra tool servers. **Prerequisite:** the manager holds its seat and has adopted the licences.
**Order:** the manager assigned and a refresh observed; the console's provision in the catalogue; this **Order:** the manager assigned and a refresh observed; the console's provision in the catalogue; this
module on one workstation; the six predecessor files and the hand-made console entry removed there; a module on one workstation; the six predecessor files and the hand-made console entry removed there; a
new session read to confirm it sees the mesh's instruction file, the console's tools under `mesh`, and new session read to confirm it sees the mesh's instruction file, the console's five tools under `mesh`, and
its licence; then the rest. its licence; then the rest.
## 7. The package ## 7. The package
@@ -178,13 +193,13 @@ installer is rejected: it puts a self-updating binary under the person's home, i
| Check | Defends | | Check | Defends |
|---|---| |---|---|
| the module's definition names no node, path or login, declares nothing under a home or `/etc`, and no file resource carries a secret | ADR 0112, ADR 0155, ADR 0183 | | the module's definition names no node, path or login, declares no file under a home or `/etc` (only the two directories), and no file resource carries a secret | ADR 0112, ADR 0155, ADR 0183 |
| on a lab machine with an account and a seeded home holding a person's rule file and the predecessor's leftovers: after assign, the managed directory holds the mesh's files, the home is byte-identical except the credentials file, which is owned by the operator and names no refresh token; after unassign, the managed directory's files are gone and the home is untouched | ADR 0182, the host's agnosticism | | on a lab machine with an account and a seeded home holding a person's rule file and the predecessor's leftovers: after assign, the managed directory holds the mesh's files, the home is byte-identical except the credentials file, which is owned by the operator and names no refresh token; after unassign, the managed directory's files are gone and the home is untouched | ADR 0182, the host's agnosticism |
| on a lab machine with no account, the assignment is refused naming the fact | ADR 0181 | | on a lab machine with no account, the assignment is refused naming the fact | ADR 0181 |
| a switch asked of the seat through the console changes the licence and the token on the node; no tool answer and no log line holds a token | ADR 0183 | | a switch asked of the seat through the console changes the licence and the token on the node; no tool answer and no log line holds a token | ADR 0183 |
| the API-key binding writes nothing under the home and the agent authenticates through the helper | ADR 0183 | | the API-key binding writes nothing under the home and the agent authenticates through the helper | ADR 0183 |
| the console's provision resolves by co-location; a machine without the console refuses the module by name | ADR 0027, ADR 0152 | | the console's provision resolves by co-location; a machine without the console refuses the module by name | ADR 0027, ADR 0152 |
| a new session on the assigned workstation lists the console's tools under `mesh` and answers "which node am I" from the instruction file | the exit of the build | | a new session on the assigned workstation lists the console's five tools under `mesh` ([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)) and answers "which node am I" from the instruction file | the exit of the build |
## What this does not settle ## What this does not settle
+13 -8
View File
@@ -2,7 +2,7 @@
layer: to-be layer: to-be
status: in-progress status: in-progress
code: [mesh-host, mesh-controller, mesh-tools, mesh-catalog] code: [mesh-host, mesh-controller, mesh-tools, mesh-catalog]
updated: 2026-10-02 updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md - 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md - 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
@@ -33,12 +33,16 @@ This is the design [to-be 29](29-a-node-has-operator-accounts.md) §2 called *a
Worked on the first one, a shell. The `zsh` module declares: Worked on the first one, a shell. The `zsh` module declares:
- a **package**, `zsh`; - a **package**, `zsh`;
- **files under the home**, owned by the account: the shell's rc file with the module's default - **files under the home**, owned by the account: the mesh's block at the start of the shell's rc
configuration, carrying a kept region for the operator's own lines, and `${setting:…}` file with the module's default configuration and the slots other modules' code lands in, the
placeholders for the few values a node varies; the account and its home are machine facts the operator's own lines kept after it, and `${setting:…}` placeholders for the few values a node
controller resolves ([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), varies; the account and its home are machine facts the controller resolves
to-be 29 §2); ([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md),
- a **seat declaration**, `login-shell`, node-scoped, with its one verb; and a **claim** on it; to-be 29 §2). Its environment is a contribution to the environment module, not lines of its own
([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
[ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
[to-be 41](41-the-shell-and-the-accounts-environment.md));
- a **claim** on the mesh's node-scoped seat `node-login-shell`, with its one verb;
- a **`user` shape** naming the shell, applied only where the module holds the seat; - a **`user` shape** naming the shell, applied only where the module holds the seat;
- a **tools bundle**, the artifact kind for interpreted code, with `execute` and the module's own - a **tools bundle**, the artifact kind for interpreted code, with `execute` and the module's own
`show-config`. `show-config`.
@@ -80,7 +84,8 @@ root escalates itself.
## 4. The seats of the environment ## 4. The seats of the environment
Decided now: **`login-shell`** (module-declared; zsh, fish, bash; verb `execute`) and Decided now: **`node-login-shell`** (the mesh's own, ADR 0204; zsh, fish, bash; verb `execute`),
**`node-environment`** (the mesh's own, ADR 0203; the environment module; no verbs) and
**`node-service-manager`** (the mesh's own; systemd; verbs over units in both scopes). The rest **`node-service-manager`** (the mesh's own; systemd; verbs over units in both scopes). The rest
are candidates from [research 018](../../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md), are candidates from [research 018](../../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md),
one record each when its first holder is written: display server, display session, terminal one record each when its first holder is written: display server, display session, terminal
@@ -15,6 +15,7 @@ decisions:
- 02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md - 02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md
- 02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md - 02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md
- 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md - 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
--- ---
# 38. Building the operator's machine # 38. Building the operator's machine
@@ -330,6 +331,13 @@ tool of each.
## WP5 — The shell, on a server first ## WP5 — The shell, on a server first
*Replaced on 2026-10-04 by [to-be 41](41-the-shell-and-the-accounts-environment.md).* A review before
assigning found that the shell module would duplicate every machine's existing startup file, drop
lines from it, leave the prompt uninstalled, and could not be unassigned
([issue 228](../../04-ISSUES/228-a-login-the-mesh-set-is-never-given-back/00-report.md)). The shell,
its environment, the modules that plug into it, and the host's fix are built and proven there. What
follows is the original plan, kept for the record.
*mesh-catalog #224, already written. Half a day to assign and prove.* *mesh-catalog #224, already written. Half a day to assign and prove.*
**Order.** Assign `zsh` to one server; push; `login-shell.execute@<server> command="uptime"` **Order.** Assign `zsh` to one server; push; `login-shell.execute@<server> command="uptime"`
@@ -2,8 +2,9 @@
layer: to-be layer: to-be
status: designed status: designed
code: [] code: []
updated: 2026-10-02 updated: 2026-10-04
decisions: decisions:
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md - 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 02-DECISIONS/0024-model-access-is-a-provision.md - 02-DECISIONS/0024-model-access-is-a-provision.md
- 02-DECISIONS/0050-model-access-is-vendor-agnostic.md - 02-DECISIONS/0050-model-access-is-vendor-agnostic.md
@@ -74,22 +75,22 @@ Carried from the predecessor, where each rule was earned by an incident:
## 4. Handing a token to a node ## 4. Handing a token to a node
Every node that runs the agent module registers that module's public key with the seat when it first *Amended 2026-10-04 by [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)*, replacing the manager's visits: **what each consumer
runs. From then on: should hold is the manager's state, and the token is fetched when it changes.**
- **On rotation**, the manager calls `claude-code.apply@<node>` on every node bound to the rotated - **The manager keeps a `bindings` state**, one key per consumer: the licence, its kind, and a
licence, with the new token sealed to that node's module key. The module answers *applied*, or **generation** that increases with every rotation and every switch. Nothing in it is secret.
*refused* and why, and the manager records it. - **The agent module on each node watches its own key.** When the generation is newer than the one it
- **On a switch**, the same call with the other licence's token, and the binding is the authority: the applied, it asks the seat's `current` verb, sending its public key, and is answered with the token
module applies a bind without comparing expiries, because across two licences the numbers are sealed to it — request/reply, never an event. A node that was away reads its key when it is back and
unrelated. asks once; a manager that is down leaves every node on its last token, which lives hours.
- **On a pull** — the module starting, or finding its token near expiry — the module calls the seat's - **On a switch** the agent applies the new licence's token without comparing expiries, because across
`current` verb for its binding and is answered sealed the same way. two licences the numbers are unrelated; within one licence it applies only a newer grant.
- **Never as an event.** What the manager emits names the licence and the outcome and carries no token. - **No event announces a rotation or a switch.** What they announced is the state itself, and a node
needs the latest, not the history. What the manager still emits names an outcome and carries no token.
A node whose module has not registered a key cannot be handed a token, and the manager says so by name A consumer that never asks is visible: its own report (§6) names the licence and generation it holds,
rather than falling silent. A node whose module refuses — a wrong identity, a stale grant within one and a node behind its binding is drift the manager reports.
lineage — is recorded as drift and reported.
## 5. Who gets which licence ## 5. Who gets which licence
@@ -116,27 +117,46 @@ already keeps.
## 6. Adopting a grant ## 6. Adopting a grant
A licence enters the mesh one of two ways, and the token never passes through a prompt, a terminal or an *Amended 2026-10-04 by [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)*: a licence is an account, learned from what the nodes
argument: report, and adopted by refreshing it.
- **From a node's login.** A person logs in on a node, as they always have. The agent module there reads - **Every node reports what it holds**, as the agent module's `holdings` state, one key per node: the
the account's identity from the agent's own state file, and offers the full grant to the seat sealed account's identity read from the agent's own state file, the kind, the refresh token's fingerprint and
to the manager's key. The manager adopts it into the licence the node is bound to **only if the whether one is present, the access token's fingerprint and expiry, the licence and generation it was
identity matches** that licence's recorded account; a licence not yet identified is identified by its last handed, when the credentials file last changed. Written when the module starts — a node already
first adoption; a mismatch is refused and notified, because the predecessor once filed one account's logged in reports at once — and on every change. Never a token.
grant into another's row this way. - **The manager reads every report at start and watches them.** A report with a refresh token whose
fingerprint the manager does not hold is a candidate: a new licence for an account it has none for, a
login made since for one it has. A manager launched for the first time holds no licence and takes every
report as a candidate.
- **The secret is asked for, never published.** For a candidate the manager calls that node's agent
module, giving its own public key, and is answered with the grant sealed to it.
- **Adopting is refreshing.** The manager exchanges the candidate's refresh token under its lease for
that account; success makes the returned grant the licence's and the manager its only rotation source;
failure records the candidate dead and adopts nothing. Candidates for one account are tried newest login
first, and the first that refreshes ends the search — the others are never exchanged.
- **The latest login wins.** A bound node is handed an access token only and its file holds no refresh
token, so a refresh token appearing there later is a person's login; its report makes it a candidate,
and if it refreshes it replaces the licence's grant.
- **A first binding follows the login**: a node with no binding whose report names the adopted account
is bound to it. Every later change is `bind`, `switch` or `release`.
- **The identity guard** files a grant under the identity the node read; where the vendor's refresh
answer names the account too, a mismatch is refused and notified. Which source decided is audited.
- **An API key** is delivered to the manager by the operator through the seat's `adopt` verb from a file - **An API key** is delivered to the manager by the operator through the seat's `adopt` verb from a file
on the manager's node, never as an argument. on the manager's node, never as an argument.
## 7. What it emits and serves ## 7. What it emits and serves
**Events**, no secret in any: `licence.rotated`, `licence.switched`, `licence.adopted`, **Events**, no secret in any: `licence.adopted`, `licence.failing`, `licence.refused`, `usage.read` —
`licence.failing`, `licence.refused`, `usage.read` — the audit logger records them all. the audit logger records them all. *2026-10-04 (ADR 0206):* `licence.rotated` and `licence.switched` are
gone; a rotation or a switch is a new generation in the `bindings` state.
**State**: `bindings`, which it keeps; the agent module's `holdings`, which it reads.
**The seat's verbs**, the contract every future holder must serve: `licences` (each with kind, **The seat's verbs**, the contract every future holder must serve: `licences` (each with kind,
identity, expiry, failures, who is bound), `bindings`, `bind`, `switch`, `release`, `refresh` (now, one identity, expiry, failures, who is bound), `bindings`, `bind`, `switch`, `release`, `refresh` (now, one
or all), `usage` (current and history), `adopt`, `register` (a node's module key), `current` (a or all), `usage` (current and history), `adopt`, and `current` (a consumer's token, sealed to the key the
consumer's token, sealed, asked by the consumer's module). consumer sends — ADR 0206). The manager asks a node for a candidate grant by the agent module's own tool.
## 8. Settings ## 8. Settings
@@ -0,0 +1,216 @@
---
layer: to-be
status: designed
code: []
updated: 2026-10-04
decisions:
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
- 02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md
- 02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md
- 02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md
- 02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md
---
# 40. Building the operator's agent and its licence manager
**The work of [design 36](36-the-operators-agent-on-a-machine.md) and [design 39](39-the-anthropic-licence-manager.md),
broken into packages that each end at something a person can see run, in the order their
dependencies allow.** The two designs are the authority on *what* is built; this document holds the
packages, their order, their sizes and their proofs, and is wrong the moment it disagrees with them.
It is the shape [design 38](38-building-the-operators-machine.md) gives the operator's machine.
*Revised 2026-10-03, after design 38's WP1–WP4b ran:* the node's tool runtime is live on all four
machines, tools are bundles it serves and each is given only the words its artifact declares, every
bundle is a child the runtime launches over stdio and is the bus for
([ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md),
[ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)),
and the console offers five tools over addresses
([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)). What changed
in this plan: the wait on design 38's WP3 is over; the manager starts every exchange, by the operator's
direction (ADR 0183's dated note); and the manager's daemon is a long-running bundle the runtime launches,
which ADR 0198 decided the same day — nothing in this plan waits on another record.
## How this is built, and where it is run
**On the live mesh, by the operator's decision** ([ADR 0149](../../02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md)).
Every package is written with unit tests, committed on one branch per repository
([playbook 07](../../00-META/process/07-feature-branches.md)), and proven on the machines: one
workstation first for the agent, the control node first for the manager, then the rest. A broken agent
module leaves a workstation's agent without the mesh's instructions or with a stale token until the next
push; the person's own files under the home are out of the failure's reach, by
[ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md).
Each package names what proves it. A package that cannot name its proof is divided until it can.
## What exists already, measured
Measured 2026-10-03 on the four machines and in the repositories.
| Piece | Today | Becomes |
|---|---|---|
| the node's tool runtime | live on all four, a host process; **runs as the operator account**; listens for the console on loopback at a port its own code fixes; launches or imports every assigned module's tools bundle and hands each its declared words | serves the agent module's tools; gains one provision for its endpoint (WP1) |
| the operator account | **stated on all four** — the runtime runs as it | read by the agent module from the runtime's own words |
| escalation | passwordless `sudo` for the operator account on all four — a fact about the machines, checked by nobody | how the agent module writes its managed directory under `/etc` |
| the agent itself | installed on all four, at four different versions, all above the one the managed tool-server key needs | declared as the module's package |
| a bundle's words | paths and constants written with `${dir:…}` and `${port:…}` only; a fact the mesh knows reaches a bundle as a file whose path is a word | the agent module's facts file and settings file |
| a bundle calling a tool | `mesh/ask` through the runtime that launched it (ADR 0198); no bundle holds a bus credential | how the manager visits every node |
| a module's own long-running code | a bundle the runtime launches and restarts (ADR 0198); the runtime's subscription and grants built, the modules moving in design 38 WP4c's waves | the manager's daemon (WP3, WP4) |
| the vendor's refresh, the sealed box, the grant file | `anthropic-manager` in the catalogue, built on the controller placement ADR 0183 moved away from; assigned to nothing | its client ported into the manager; the module retired (WP6) |
| the credentials write, the strip, the identity read | `anthropic-consumer` in the catalogue; tested; assigned to nothing | ported into the agent module with its tests; the module retired (WP6) |
| the predecessor's manager and consumer | the lease per licence, the expiry floor, the lineage comparison, the identity guard, three touchpoints, cooldowns | ported as logic with its tests |
## The order the work allows
```
WP0 the operator names the licences and each node's role (the live mesh) ── an hour
WP1 the runtime provides its endpoint (mesh-tools) ── small
WP2 the agent module (mesh-catalog) ──┐ WP2 needs WP1;
WP3 the manager's code, built and tested (mesh-catalog) ──┘ WP3 is independent
│
WP2 live: one workstation, configuration only — no licence yet ── the first live proof
│
WP4 the manager live on the control node ── its daemon a long-running bundle (ADR 0198)
WP5 the licence end to end on one workstation
WP6 the rest of the nodes, and the predecessor's remains
```
## WP0 — The operator names the licences and each node's role
*The live mesh. An hour, and it is the operator's.* The accounts are stated already. What remains: the
names of the two subscription licences and the API key; each node's role, as the agent module's setting
on the node layer once the module is registered.
**Proof.** The module's settings list a role for every node; the licences have names.
## WP1 — The runtime provides its endpoint
*mesh-tools. An hour.*
**What changes.** The `node-tools` manifest provides a node-scoped provision, `mcp-endpoint`, serving
the port its code listens on, the way the local model server serves its API
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)). Co-location
resolves it. The runtime's port stays what its code fixes; assigning it is
[issue 192](../../04-ISSUES/192-the-meshs-tools-reach-a-person-only-by-a-registration-made-by-hand/00-report.md)'s
second question and not this package's.
**Proof.** The plan for a workstation carrying a consumer of `mcp-endpoint` shows it bound to the
runtime's port; the controller's tests and the catalogue's checks pass.
## WP2 — The agent module
*mesh-catalog. A day and a half.*
**What is written**, as design 36 says:
1. **The manifest.** The agent's package. A state directory. A facts file in it, rendered by the mesh:
the node's name and the console's address from `mcp-endpoint`. A settings file in it, merged from
the module's settings layers: the node's role and the extra tool servers. A tools bundle whose
words name the two files, the state directory and nothing else. The two directories it owns declared — the agent's managed
directory and `~/.claude` — and **no file resource under either.**
2. **The renderer**, run whenever the runtime collects the module's tools: from the two files, the
managed settings file (the tool servers under the entry `mesh`, the attribution trailers, the
key-helper for an API-key binding) and the managed instruction file, written under the agent's
managed directory through the account's escalation, only when their content changed.
3. **The keypair**, made once in the state directory; X25519 and an authenticated cipher from the
language's own library, so the bundle carries no dependency.
4. **The tools and the state** (*2026-10-04, ADR 0206*): `claude_code_status` (what is rendered, what
licence is held, when its token expires, fingerprints only); `claude_code_render` (render now);
`claude_code_grant` (the full grant in the credentials file, sealed to the key the manager gives).
The `holdings` state, written at start and on every change of the credentials file; a watch of the
manager's `bindings` key for this node, which asks the seat's `current` on a newer generation and
applies the sealed answer — only if newer within one lineage unless it is a switch; the credentials
write as the operator, access-token-only, atomic; the key-helper program for an API key.
5. **The documentation**: the six predecessor files and the hand-made console entry a person removes.
**Proof, before anything runs live.** Unit tests: the renderer writes the mesh's keys and nothing else;
it writes nothing when nothing changed; the credentials write strips a refresh token and is atomic; the
lineage cases from the predecessor; a sealed hand-over opens only with the module's key; no tool's answer
contains a token. The catalogue's checks pass.
**Proof, live, on one workstation, configuration only.** Assign the module; set the node's role; push.
The agent's managed directory holds the two files; everything under the person's agent directory is
byte-identical to before; a new session lists the console's five tools under `mesh` and answers *which node am
I* from the managed instruction file. `claude_code_status` answers through the console. No licence is
touched: the module writes the credentials file only when it is handed a token.
## WP3 — The manager's code, built and tested
*mesh-catalog. Two to three days.*
**What is written**, as design 39 says: the manifest (the seat and its verbs, a database, a `secret`
for the key the grants are encrypted with, a tools bundle and a long-running bundle for the daemon, both launched by the runtime, settings
with defaults); the store's migrations; the refresh with its plan, lease, floor and cadence as pure
functions; the vendor client from `anthropic-manager`; adoption from a file and from a node's waiting
login with the identity guard; usage and its threshold; the seat's verbs. *2026-10-04 (ADR 0206):* in place
of the visit, the watch of every node's `holdings`, adoption of a candidate by refreshing it (newest login
first, once per account), the `bindings` state with a generation per consumer, and `current`.
**Proof, before anything runs live.** Unit tests: two refresh runs started together rotate one grant
once; a mismatching identity is refused; a worker bound to a dead licence is refused and never lent
another; nothing the daemon emits carries a token; a hand-over sealed for one node opens with no other
node's key.
## WP4 — The manager live on the control node
*The live mesh. Half a day.* The daemon is a long-running bundle the control node's runtime launches
(ADR 0198); it calls each node's agent module by `mesh/ask`. If the runtime's half of ADR 0198 is not
yet live on the control node when this package starts, this package waits for it: no tool container, no
credential copied by hand.
**Order.** Assign the manager on the control node; push. It reads every node's `holdings` and adopts each
account the nodes are logged in to, by refreshing the newest login's grant (ADR 0206); each node with no
binding is bound to the account it reported. Adopt the API key from a file there. A second subscription
account enters by a login on a workstation carrying the agent module.
**Proof.** Through the console: `anthropic-licence-manager.licences` lists three licences with identity
and expiry; within the cadence the audit shows a rotation and a later expiry; a forced `refresh` is
logged with the vendor's answer.
## WP5 — The licence end to end on one workstation
*The live mesh. Half a day. The proof of the whole.*
**Order.** Record the checksums under the person's agent directory. Bind the workstation to a
subscription licence. Remove the six predecessor files and the hand-made console entry. Start a session.
**Proof.** Everything under the person's agent directory is byte-identical but the credentials file,
which is owned by the operator, readable by nobody else, and names no refresh token. A session makes a
model request. `switch` to the second subscription licence changes the token on the workstation within a
minute, and neither the verb's answer nor either module's log holds a token. Switched to the API key, the
credentials file is left as it was and the agent authenticates through the key-helper. Switched back.
## WP6 — The rest of the nodes, and the predecessor's remains
*The live mesh and mesh-catalog. One day.* The module on every node, the predecessor's files removed on
the second workstation; a login under a licence's account collected and adopted, and one under the wrong
account refused and notified; `anthropic-manager` and `anthropic-consumer` retired from the catalogue;
designs 36 and 39 set to `implemented` with the as-is written
([playbook 02](../../00-META/process/02-graduation.md)).
**Proof.** `claude_code_status` answers on every node; the refusal's notification arrived; the catalogue
has no module built on the old placement.
## What is deliberately not here
- **The package repository seat** for a distribution that does not carry the agent's package (design 36
§7). The four machines have the package; a fifth would refuse the module in its package manager's
words.
- **Escalation as a checked fact.** The agent module's write under `/etc` relies on the operator
account's passwordless `sudo`, true on all four and checked by nothing. A machine without it refuses
the render in the tool's own words; making escalation a reported capability is design 38's to decide.
- **An automated switch on exhaustion.** The readings are kept from WP4; the policy is a later record.
- **Workers and the mesh's own sessions as consumers.** The manager knows them from WP3; the consumers do
not exist yet ([to-be 15](15-the-agent-session.md)).
- **Whether a refresh token is single-use.** WP4 may measure it; the design holds either way.
## How this list is kept true
Each package's proof is run when the package is finished and its line here gains the date and the
commit, as design 38 does. A package whose proof fails is not reworded; the failure is recorded under it
and the package stays open. When WP2's live proof runs, design 36 moves to `in-progress` with its owning
repository; when WP5's does, design 39 does too; and when WP6's does, both move to `implemented`, with
the as-is written.
@@ -0,0 +1,238 @@
---
layer: to-be
status: in-progress
code: [mesh-host, mesh-controller, mesh-catalog]
updated: 2026-10-04
decisions:
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
---
# 41. The shell and the account's environment
What it takes for the operator's shell to be modules, without a machine losing anything it does
today. This design replaces the shell half of
[to-be 38](38-building-the-operators-machine.md) WP5, and finishes the service-manager module of WP6
short of its user-scoped units. The decisions are [ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)
(the environment), [ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
(shell code and the seat) and [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md)
(vendored software). The evidence is [research 025](../../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md).
## What a node with a shell looks like
```
toolchain, agent, … prompt, plugins, version manager
│ environment │ shell (zsh, slot)
▼ ▼
node-environment ── holds ── node-env node-login-shell ── holds ── zsh
│ │
├─▶ ~/.config/mesh/environment.sh ◀─ sourced from zsh's block in ~/.zshenv
└─▶ ~/.config/environment.d/50-mesh.conf ◀─ read by the account's service manager
│
~/.zshrc: the mesh's block FIRST — defaults and the
three slots — then the operator's own lines, kept
```
**The environment module** (`node-env`) holds `node-environment`. It has no package and no process.
Its two files are written by the host from placeholders the controller fills.
**The shell module** (`zsh`) holds `node-login-shell`. It:
- installs the package;
- sets the login shell through the `user` shape;
- writes two blocks:
- one in `.zshenv`, sourcing the environment;
- one at the start of `.zshrc`, holding the defaults every machine shares today: the title, the
keybindings, the aliases and the two small functions, with the three slots in place;
- contributes its own environment: the editor, the configuration home, and `~/.local/bin` plus the
two script directories on `PATH`;
- serves `execute` and its own `zsh_config`.
**The prompt module** (`powerlevel10k`) ships the theme as a pinned vendored archive, and the prompt's
configuration as its own file in a directory it owns. It contributes the zsh code that loads both.
**Two plugin modules** (`zsh-autosuggestions`, `zsh-syntax-highlighting`) each install their
distribution package and contribute one line. Syntax highlighting goes in the `last` slot, which is
what its upstream asks for.
**What stays the operator's** is everything below the block in `.zshrc`, and `~/.zshrc.local`, which
the operator's lines source as they do today. On every machine today that means:
- the version manager's lines and the toolchain's `PATH` entry, until those modules exist;
- the two variables naming the operator's own script library;
- the agent's title variable;
- the port aliases;
- the workstation's desktop variables, which live in `~/.zshrc.local` already.
Nothing is lost at any step, because a line moves out of the operator's part only when a module
carries it.
**The migration is a person's act**, listed in the zsh module's documentation (ADR 0182): after the
first push, delete from `.zshrc` the lines the block now carries. Until then they run twice, which is
harmless and visible.
## Work packages
```
WP1 the host gives a login back (mesh-host) issue 228
WP2 the controller composes environment and shell code (mesh-controller)
WP3 the modules (mesh-catalog) needs WP2 to resolve
WP4 the service manager's module, finished (mesh-catalog) independent
WP5 assign and prove (operator-gated) needs WP1–WP3 merged and rolled
```
WP1, WP2 and WP4 are independent, and are built in parallel on one feature branch per repository
([playbook 07](../../00-META/process/07-feature-branches.md)). WP3 is written in parallel and proven
against WP2's controller before anything is published.
## WP1 — The host gives a login back
*mesh-host. Half a day. [Issue 228](../../04-ISSUES/228-a-login-the-mesh-set-is-never-given-back/00-report.md).*
**What changes.**
- The `user` shape records, in its applied record, the login shell it found whenever it changes it.
- Removing a `user` never deletes the account, whether or not the mesh created it. If the account's
shell is still the one the mesh set, and the recorded shell is still executable, the recorded shell
is set back. Otherwise the shell is left as it is, and the outcome says why.
- Before a shell is set, it is refused unless it is executable and listed among the machine's shells.
The exception is a shell that refuses logins (`nologin`, `false`): the distribution does not list
those, and the controller's own account uses one, so it need only be executable. The refusal fails
that resource and leaves the account untouched.
- A directory the host creates on the way to a file, a block or an archive inside an account's home
belongs to that account, the home itself included when the host makes it. A directory that was
already there keeps its owner and mode (ADR 0182). Until this, a fresh account's `~/.config` or
`~/.local/share` would have been created as root's.
- Giving the shell back is reported, never fatal. A failed `usermod` on removal is named in the
outcome and the record is dropped, because a fatal removal is exactly the wedge issue 228 is about.
**Proof.** The host's tests:
- an undeclared `user` no longer stops the apply;
- the found shell comes back;
- a shell changed by a person since is left alone;
- a missing shell is refused before `usermod` runs;
- a created account survives its removal.
## WP2 — The controller composes environment and shell code
*mesh-controller. One to two days.*
**What changes.**
- **The seat table.** It gains `node-environment` (node scope, no verbs) and `node-login-shell`
(node scope, the verb `execute`). A module may no longer declare a seat named `login-shell` or
`node-login-shell`. Both new seats are seeded into a live store by the existing additive seeding.
- **The manifest.** It gains two contribution fields, each refused at parse when malformed:
- `environment`, with `variables` and `path`: a variable name must be a POSIX name and not `PATH`;
a value may not contain `$`, a quote, a backslash or a line break; a path entry's place is
`start` or `end`;
- `shell`: each entry names a known shell, a known slot, and non-empty code.
- **Composition.** It fills `${environment:posix}`, `${environment:systemd}` and
`${shell:<shell>:<slot>}` in the claiming holder's file contents, from every module assigned to
the node. The rendering is ADR 0203's and ADR 0204's: module order, a naming line per contribution,
`PATH` entries added only when missing. `${machine:…}` in a contributed value is resolved first.
- **Refusals.** A variable set by two modules on one node is refused, naming both. A placeholder in a
module that does not claim the matching seat is refused, both at the catalogue check and at
composition.
**Proof.** The controller's tests:
- both environment renderings, byte for byte, from a fixed set of contributions;
- the POSIX rendering sourced twice by `sh` leaves `PATH` unchanged;
- slot order and per-shell filtering;
- each refusal, by name;
- the seat table carries both seats and refuses a module declaring either.
The catalogue check over the whole catalogue passes.
## WP3 — The modules
*mesh-catalog. One day.*
**What changes.**
- **`node-env`, new.** It claims `node-environment` and declares two owned files: the POSIX file at
the path the seat fixes, and the service manager's file, each holding its placeholder. It declares
no tools.
- **`zsh`, rewritten.**
- It drops its seat declaration and claims `node-login-shell`.
- Its environment moves to a contribution.
- It writes a `.zshenv` block that sources the environment file.
- Its `.zshrc` block goes at the start and carries today's shared defaults, with the three slots.
- It keeps the `user` shape.
- `execute` runs `zsh -lc` in the account's home, with the runtime's session words for the user
manager. Its timeout is bounded below the runtime's thirty-second call limit; its output is cut at
a bound and marked as cut; on timeout it kills the process group.
- Tests cover the tool over real child processes and the manifest's shape.
- Its documentation lists the one-off migration.
- **`powerlevel10k`, new.**
- The theme is vendored at a pinned upstream release, with its licence, as an archive artifact
unpacked into the module's directory under the account's home.
- The prompt configuration is today's file, as its own owned file in the same directory.
- It contributes the zsh code that loads the theme and the configuration.
- Today's file has the instant-prompt cache commented out, so the module does not turn it on.
- **`zsh-autosuggestions` and `zsh-syntax-highlighting`, new.** Each declares its package and
contributes its loader from the distribution's path, in the `normal` and `last` slots.
**Proof.** The controller's catalogue check over the whole catalogue passes. The modules' tests pass.
A rehearsal composition for a node holding all five shows:
- the `.zshrc` block with the prompt in `normal` and highlighting in `last`;
- the environment file with the shell's `PATH` entries;
- the service manager's file.
## WP4 — The service manager's module, finished
*mesh-catalog. Half a day. From the review of 2026-10-04.*
**What changes.**
- System-scope `start`, `stop`, `restart`, `enable` and `disable` escalate with `sudo -n` when the
runtime is not root, as the packet filter and intrusion modules do. They name a refusal by how it
failed.
- User scope reaches the account's manager by its runtime directory, which the runtime's environment
lacks.
- A failed `systemctl` is an error, not an empty list.
- The package resource goes: the service manager is always present, and it collided with the network
module's identical declaration on a machine running both.
- `status` says whether the mesh declares the unit. The restore note is attached only to such a unit.
- Tests cover a fake runner.
The user-scoped units of mesh-host #72 stay to-be 38's WP6.
**Proof.** The module's tests. Live, after WP5:
- `node-service-manager.units` answers in both scopes on a workstation and on a server;
- `restart` of a harmless unit answers `ok`.
## WP5 — Assign and prove
*Operator-gated. Nothing here runs without the operator's go-ahead.*
**Order.**
1. Merge WP1 and roll the host.
2. Merge WP2, and push the controller.
3. Merge WP3 and WP4, and build the new modules by hand: a new catalogue module's first build is asked
for, not automatic.
4. On one server, assign `node-env`, `zsh`, `zsh-autosuggestions` and `zsh-syntax-highlighting`, and
push. Then check:
- `node-login-shell.execute@<server> command="echo $PATH"` shows the shell's entries;
- `.zshrc` begins with the block;
- the operator's lines follow untouched;
- the environment file and the service manager's file exist.
5. The operator deletes the duplicated lines, per the zsh module's documentation.
6. The other server, then the two workstations, the workstations also with `powerlevel10k`.
7. Assign `systemd` everywhere, and prove WP4.
8. Unassign one plugin module on one machine. Its line leaves the block at the next push, and nothing
else changes.
The follow-up records to-be 38 names are still owed:
- what a shell module assigned beside the holder does;
- how a person's own environment variable is a setting rather than a line, once issue 168 closes.
+1
View File
@@ -43,6 +43,7 @@ document is written and this one's status becomes `implemented`.
| [`32-what-a-module-declares.md`](32-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md), superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) | | [`32-what-a-module-declares.md`](32-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md), superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) |
| [`37-the-operators-machine.md`](37-the-operators-machine.md) | **In progress.** Every configurable thing on a node is a module, the home included; one default per module varied by settings or kept regions; roles a machine has once as seats with tool contracts; one tool runtime per node on the host side | [ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md), [0174](../../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md), [0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0176](../../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md), [0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md) | | [`37-the-operators-machine.md`](37-the-operators-machine.md) | **In progress.** Every configurable thing on a node is a module, the home included; one default per module varied by settings or kept regions; roles a machine has once as seats with tool contracts; one tool runtime per node on the host side | [ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md), [0174](../../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md), [0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0176](../../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md), [0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md) |
| [`38-building-the-operators-machine.md`](38-building-the-operators-machine.md) | **In progress.** The work of design 37 as packages: the runtime serves many modules, the controller composes one per node, the console becomes its serving mode, the packet filter moves first, then the shell and the service manager — tested on the live mesh by the operator's decision | [ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [0149](../../02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md) | | [`38-building-the-operators-machine.md`](38-building-the-operators-machine.md) | **In progress.** The work of design 37 as packages: the runtime serves many modules, the controller composes one per node, the console becomes its serving mode, the packet filter moves first, then the shell and the service manager — tested on the live mesh by the operator's decision | [ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [0149](../../02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md) |
| [`41-the-shell-and-the-accounts-environment.md`](41-the-shell-and-the-accounts-environment.md) | **In progress.** The shell and the account's environment as modules: an environment module every module contributes variables and `PATH` entries to, shell code contributed to the login shell in named slots, the prompt and plugins as modules, the host giving a login back, and the service manager's module finished | [ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md), [ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md), [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md) |
## Not yet written ## Not yet written
@@ -2,7 +2,7 @@
status: resolved status: resolved
opened: 2026-09-26 opened: 2026-09-26
located-in: [mesh-controller internal/catalogue, mesh-sdk src/provisioner, mesh-catalog modules/minio] located-in: [mesh-controller internal/catalogue, mesh-sdk src/provisioner, mesh-catalog modules/minio]
fixed-by: 02-DECISIONS/0201-a-provider-declares-what-it-derives-for-each-consumer.md fixed-by: 02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md
amended-design: 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md amended-design: 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
--- ---
@@ -64,7 +64,7 @@ compares it to what the provider will actually create. The one wrong instance wa
bucket in its own configuration against the one the provider would create is a check that could bucket in its own configuration against the one the provider would create is a check that could
exist today, for any interface, without the mechanism above. exist today, for any interface, without the mechanism above.
## Answered, 2026-10-02 — [ADR 0201](../../02-DECISIONS/0201-a-provider-declares-what-it-derives-for-each-consumer.md) ## Answered, 2026-10-02 — [ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md)
The channel is the provider's own `serves` block, which may now name the consumer the mesh is The channel is the provider's own `serves` block, which may now name the consumer the mesh is
serving: `${consumer:as}` and `${consumer:as:dns}`. The mesh fills it once, where it knows who the serving: `${consumer:as}` and `${consumer:as:dns}`. The mesh fills it once, where it knows who the
@@ -0,0 +1,64 @@
---
status: open
opened: 2026-10-04
located-in: [mesh-host internal/apply/apply.go, mesh-host internal/apply/schedule.go]
fixed-by:
amended-design:
---
# 224 — An apply arriving during a maintenance window reopens it, by recreating the container the window is holding still
## What was observed
Reviewing [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)'s
`while-stopped` before merging it, 2026-10-04. Found by reading, not by running.
A scheduled step may hold its module's containers still while it runs. The host stops them, runs
the step, starts them again. Nothing tells the **apply** that a window is open, and the apply's
rule for a container it finds stopped is to replace it:
```
case existed && (before.Spec == want || legacy) && before.Running && len(reasons) == 0:
out.Action = "unchanged"
case existed:
rm -f
```
`before.Running` is false for a container a window is holding, so the second branch takes it:
the container is removed and recreated, **running**, in the middle of the step that required it
to be still.
## Why it matters
For the store, which is what the field was built for, the chain is: a push lands at 03:30 → the
apply recreates the registry → the registry accepts an upload from a build running at the same
time → `garbage-collect`, already past its mark phase, sweeps the blob that upload just wrote.
The image is then in the store with a layer missing, and the build that made it reported success.
Two things have to coincide, so it is not likely. It is also not rare enough to leave unsaid: the
mesh pushes on every merge, at any hour, and a collection over a store this size is minutes rather
than seconds.
**The general shape is the one that matters.** `while-stopped` is the first thing in the mesh that
makes a container's stopped state *intentional*. Everything else in the host reads "stopped" as
"broken, fix it", which is right everywhere else and wrong here. Any future use of the field
inherits this.
## What this is not
Not a regression. The store has never collected anything, so nothing is worse than it was; this
is a hole in something new rather than something that broke.
## Open questions
- **Should a window take the apply lock?** The daemon already serialises applies with `applying`
and, across processes, with `store.Lock`. A window that held it would make the race impossible.
The cost is that a push arriving mid-window waits for minutes, and a push that waits is what
[issue 185](../185-a-refused-membership-publish-stops-the-controller/00-report.md)'s
family of outages looked like from outside.
- **Or should the apply learn that a container is held?** Narrower: the scheduler says which
containers a window currently holds, and `applyContainer` reports those unchanged instead of
recreating them. Nothing blocks, and the apply tells the truth for the minutes it matters —
at the cost of a second source for "is this container meant to be running".
- Either way: should the *report* say a window is open, so a machine that looks half-stopped at
03:31 reads as working rather than broken?
@@ -0,0 +1,75 @@
---
status: open
opened: 2026-10-04
located-in: [mesh-host internal/apply, mesh-controller internal/catalogue]
fixed-by:
amended-design:
---
# 225 — A provisioner cannot read the grant secrets since its code left the container, and every consumer of it is unserved
## What was observed
On the control machine, 2026-10-04, found while looking at why one app was restarting:
```
[mongodb] [provisioner:mongodb-database] mesh_novox_photos: secret not readable yet
(/var/lib/mongodb/grants/novox.photos.secret):
Error: EACCES: permission denied, open '/var/lib/mongodb/grants/novox.photos.secret'
```
**4330 times, every five seconds, since 01:30:20.** The consequence is not a log line: the
provisioner never reads the password, so it never creates the user, so the consumer never
connects —
```
UserNotFound: Could not find user "mesh_novox_photos" for db "admin"
```
— and the app crash-loops. Two consumers on this machine are in that state.
## Why
The grant secrets are what the mesh seals for each consumer and the host unseals beside the
provider's contributions file. They are written `-rw------- root root`, which was right while a
module's own code ran in a container as root.
[ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)
moved a module's long-running code out of its container and under the node's runtime, which runs
as the operator's account. The provisioner is now that account; the secret is still root's. The
timestamps say it exactly: the files are dated 2026-09-26, the first refusal is 01:30:20 on the
day the runtime rolled.
**Nothing reports it.** The machine applies cleanly and reads as current; the provisioner says
`secret not readable yet`, whose wording is for a real and ordinary race on the first pass — the
host has not written the file yet — and which is indistinguishable, in the log, from a permanent
refusal. Four thousand occurrences of a message that means "wait a moment" is the shape to
recognise.
## Why it matters beyond this instance
This is every provider that provisions. The grant secret is the one file the sdk's harness reads
for every consumer, so a provider that cannot read it serves nobody — and says so only in a line
that reads like patience.
It is also the general question the runtime move leaves: **what the mesh seals for a module is
owned for the shape that module's code used to have.** Each module whose code moved is a module
whose files may now be unreadable to it, and ownership is the mesh's to state, not the module's
to work around.
## What this is not
Not caused by [ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md)
or [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md), which landed two
to three hours after the first refusal. Those rebuilt the two affected consumers, which recreated
their containers and made a silent fault visible as a restarting one. The dates are above.
## Open questions
- Who owns a grant secret now — the module's account, as `secrets-owner` already says for a
module's own secrets? Then the host writes it so, and this is a one-line statement in the
declaration rather than a convention.
- Should `secret not readable yet` stop saying "yet" after the first few passes? A message that
is right once and wrong four thousand times is a message that hides its own meaning.
- Which other modules' files did the runtime move leave behind? The sweep is the same question
for every path the mesh writes for a module: directories, bundles, received files.
@@ -0,0 +1,58 @@
---
status: open
opened: 2026-10-04
located-in: [mesh-controller cmd/mesh-controller/collect.go, mesh-controller internal/inventory/collection.go]
fixed-by:
amended-design:
---
# 226 — The store's sweep stops at the first reference recorded with an address, so it collects nothing at all
## What was observed
The first live run of [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)'s
sweep, 2026-10-04, printed on every build:
```
the artifact store kept 127.0.0.1:5100/mesh-tools/build@sha256:0de48cd3…, so nothing more was
asked of it: 127.0.0.1:5100/mesh-tools/build@sha256:0de48cd3… is not a reference into the
mesh's artifact store
1681 more to collect; the next build asks again
```
Nothing is collected, and nothing ever will be. The store holds 1681 artifacts the mesh no longer
keeps and the feature that exists to remove them is inert.
## Why
Two correct decisions meeting badly.
**A reference recorded before references were kept without an address** is
`127.0.0.1:5100/<path>@sha256:…` rather than `artifact-store://<path>@sha256:…`
([04-ISSUES/102](../102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md)).
`LetGo` rightly refuses to compose a delete for a reference whose shape it does not recognise —
that refusal is what keeps the sweep from reaching something that is not the mesh's.
**The sweep stops at the first refusal**, because "a store that refuses one refuses all of them"
— deletion disabled, the store down, the network gone — and pushing through would mean a hundred
identical failures in front of whoever was building something. That reasoning is right for the
store refusing. It is wrong for *this* record being unreadable.
So one old record, early in the oldest-first order, halts the whole sweep for ever.
## Why it matters beyond this instance
**A guard that cannot tell "I will not ask about this" from "it would not answer" stops the wrong
amount of work.** The two deserve opposite responses: skip one, abandon the other. Collapsing
them into "an error" is how a bounded, cautious loop becomes a loop that does nothing — and it
reports the right number while doing it, which is what made it look healthy.
## What a fix has to settle
- A reference the sweep cannot address is **skipped, and the sweep goes on** — it is a fact about
that record, not about the store.
- `Recorded()` already normalises the old form to the kept one, and is what the rest of the mesh
uses for exactly these references. The sweep should normalise before asking rather than refuse.
- Only a refusal *by the store* ends a sweep.
- **How it is checked:** a sweep over records holding one address-recorded reference and one kept
one collects the second; a sweep against a store that refuses stops at the first.
@@ -0,0 +1,44 @@
---
status: open
opened: 2026-10-04
located-in: [mesh-catalog modules/photos]
fixed-by:
amended-design:
---
# 227 — The photo app's admin client asks for port 80, which the reverse proxy holds, so it cannot start
## What was observed
Applying the control machine, 2026-10-04:
```
applying "photos.admin-client": starting container photos-admin-client:
failed to bind host port 0.0.0.0:80/tcp: address already in use
```
Port 80 on that machine belongs to the reverse proxy (`mesh-route-proxy`, confirmed with `ss`),
which is the whole arrangement: the proxy holds the public ports and every module is reached
through it. A module that publishes 80 itself can never start beside it.
Everything else on the machine applied; this one resource fails every pass.
## How it surfaced
`photos` had been pinned at a commit from 2026-09-28 and was rebuilt to `main` on 2026-10-04 —
forced by [ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md)'s
refusal of its transcribed bucket name. The admin client is one of the changes that came with the
rest of `main`. The rebuild did not create the conflict; it delivered it.
**A module pinned months behind carries whatever its branch gained, all at once, the first time
something makes it move.** That is the cost of a pin, and it is paid in full rather than
gradually.
## What a fix has to settle
- Which port the admin client should ask for, or whether it should be reached through the proxy
like everything else and publish nothing.
- Whether a module declaring a port the machine's proxy already holds should be refused when it
is composed, rather than failing on the machine every pass. The mesh assigns ports
([ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md)); a fixed 80 beside a proxy is
a statement it could check.
@@ -0,0 +1,52 @@
---
status: located
opened: 2026-10-04
located-in:
- mesh-host
fixed-by:
amended-design:
---
# 228 — A login the mesh set is never given back, and undeclaring one stops the node applying
## What was observed
2026-10-04. Before the shell module of to-be 38 WP5 was assigned anywhere, a review traced what the
host does with the `user` shape the module declares (the operator account, with the login shell zsh)
on three events: first assign, a later push, and unassign.
1. **Undeclaring a `user` stops the node applying anything, for good.**
- The host's removal has no case for a `user`, so the orphaned record fails with "no way to remove".
- Orphans are removed before the declaration's first resource, and that failure aborts the apply.
- The record stays in the host's store, so every later apply fails the same way.
This was reproduced in a throwaway test against the host's code: the apply produced no outcomes,
and an unrelated file in the same declaration was not written. Renaming the resource's id has the
same effect. A showcase module carries a `user` today and is exposed to it too.
2. **The shell the account had is never recorded.** [ADR 0176](../../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
§2 says the host "gives back [the login shell] when the holding moves". The host keeps nothing to
give back.
3. **A login shell is set whether or not it exists.** A failed package install does not stop the
resources after it. `usermod --shell` on the distribution only warns about a missing or
non-executable shell, and succeeds. The host's read-back compares the user database's string,
which matches. So an account can be pointed at a shell that is not there, and console, ssh and
display-manager logins then fail. No machine hit this, because zsh was already installed on all
four.
## Why it matters beyond this instance
Unassigning any module with a login in it, the case the mesh promises is ordinary, wedges the
machine's applies until a person edits the host's store. It is the same class of failure as an
earlier archive that could not be removed: a shape the host can create and cannot take away.
## Located
mesh-host, `internal/apply`: `remove()` has no `user` case, and `applyUser` neither records the shell
it replaced nor checks the shell it sets. The fix is set out in
[to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md) WP1:
- a removal that never deletes an account;
- the login shell given back, if it is still the one the mesh set and the recorded one still exists;
- a shell refused before it is set unless it is executable and listed among the machine's shells
(a shell that refuses logins need only be executable, since the distribution does not list it and
the controller's own account uses one).