Merge main: the ufw record renumbered to 0180, and ADR 0175 retires the per-module tool runtime this one ships

This commit is contained in:
2026-10-02 17:28:23 +02:00
31 changed files with 1788 additions and 13 deletions
+2
View File
@@ -78,6 +78,8 @@ capability. The host hardcodes no firewall, supervisor, package manager or runti
generic apply primitives and platform detection, so it runs where none of those exist — an Android
phone has no ufw, systemd, pacman or Docker.
> **The mechanism changed — 2026-10-02, by [ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md).** The shell example above — *bash, zsh and fish all join `shell`; one may be default* — is read as *installed is not holding*: the three may all be installed, and the `login-shell` seat is node-scoped and held by exactly one. The decision — what a module is, and the three relationships — stands; [ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) applies it to the operator's whole machine.
## Consequences
- **Supersedes the earlier "grouped by domain" decision** (folded in consolidation; see the
@@ -9,6 +9,8 @@ extends: 0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
# 47. A module runs its code as its own process, with its own account
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** A module's *tools* are no longer served by the module's own process under its own account: one tool runtime per node, on the host side, serves every assigned module's bundle. A tool is still served on its own subject and only the module that serves it answers; what moved is the process and the account.
## Context
A module is one self-contained thing ([ADR 0040](0040-what-a-module-is.md)), and it gets a broker
@@ -204,3 +204,14 @@ only, the refresh token only, the manager node only.**
record extends, amended to describe the adapter generalisation.
- The read-only vendor-agnostic analysis, 2026-09-05 (code workspace) — the inventory and the decisions
taken on the open questions this record encodes.
> **The mechanism changed — 2026-10-02, by [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md).**
> What stands: `model-access` is one vendor-blind provision for the consumers that do not care which
> vendor answers; a vendor's lifecycle is an adapter's; the carve-out that one node holds a refreshable
> grant's refresh token readably. What moved: the Anthropic adapter is no longer a part of the
> controller's licences context but a module, `claude-licence-manager`, holding the seat
> `anthropic-licence-manager`, with the grants in its own store encrypted with a key the vault made for
> it; and the agent at a terminal is not a consumer of `model-access` — it is coupled to an Anthropic
> grant and uses the seat. The consequence above that the three binding columns *become three ordinary
> consumers of `model-access`* therefore no longer describes the agent's bindings; they are the
> manager's. The static-key adapters and the vendor-blind records stay where this record put them.
@@ -283,3 +283,13 @@ modules in the catalogue require it — so a shared secret is a requirement answ
which is what this record asks for. Private keys are still made where they are used and never
travel, which is the other half and was never in question.
> **The mechanism changed — 2026-10-02, by [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md).**
> What stands: every shared secret the mesh makes is the vault's, a private key is made where it is
> used, and a long-lived value a backend issues enters the vault's custody — here as the key the vault
> makes for the licence manager, which encrypts the vendor's grants at rest with it. What this record
> did not foresee: a credential that lives hours, issued by a vendor to the one module that holds its
> grant, and handed by that module to the agent on each node sealed to that node's module key, on
> request/reply over the bus, never through the vault and never as a file the host writes. ADR 0183
> states that as a bounded exception — one vendor, tokens that live hours, one recipient per message —
> and a second such channel is a decision of its own.
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-ow
# 150. A module's own code runs as supervised processes under the module's one account
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** For a module's *tools*, read that record: one runtime per node, the node's one account, bundles loaded from the memberships. This record still governs a module's long-lived processes — a daemon, a provisioner, a scheduled ingest — and the account invariant for them.
## Context
The repository answers "what runs a module's own code" two ways and reconciles them nowhere
@@ -111,6 +111,8 @@ operator owns; narrowing what it may call is a setting on its assignment, which
[ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) already provides for
and nothing here builds.
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** The surface stays a module assigned per node, on loopback, with the machine's login as the authority. It is no longer a container: it is the node tools runtime's serving mode, host-side, and that runtime also serves every assigned module's tools. The module is renamed `node-tools`.
## Consequences
- **The way a person drives the mesh is inside the mesh.** It is declared, delivered, replaced and
@@ -94,6 +94,13 @@ itself restarts, and the console says so rather than hiding the modules' tools w
**A grant of `*` reaches a role's tools; `seat:<seat>.<verb>` grants one.** The console's `*` needed no
change to reach the mesh's verbs, which is what a grant meaning *every tool* should mean.
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).**
> The seat gains a generic verb beside the named ones: `command`, which takes one command line as the
> controller's binary takes it and answers what it printed. The named verbs stand and keep their
> schemas; `command` is the whole binary, added because the operator decided any node may call any
> tool and a verb per command was the only thing keeping `node account`, `node show` and the rest
> behind a shell on the control node. Additive within the version, as §"additive" above allows.
## Consequences
- **The console answers the mesh's own questions.** Issue 147's first paragraph closes: what a node
@@ -0,0 +1,108 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0040-what-a-module-is.md
---
# 173. The operator's machine is the mesh's, and a module is whatever it declares
## Context
[ADR 0040](0040-what-a-module-is.md) says a module is *one self-contained piece of software the
mesh installs and manages*, and every example it gives is a service: a database, an analytics
server, a forge. The catalogue followed the examples. Of the predecessor's 34 modules on one
workstation, 28 are the operator's environment — a login manager, a window manager with 88 files
and four flavors, a shell, a terminal, a launcher, an audio setup, scripts — and the migration
scoped all 28 out as *the workstation's own environment*, to be managed by nobody
([research 018](../01-RESEARCH/018-the-operators-machine-as-modules/02-what-exists-and-what-is-missing.md)).
Since the predecessor retired, nobody is exactly who manages them: a fix is a hand edit that
nothing records and nothing regenerates.
[To-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) reached under the home for
one directory and drew a boundary inside it. The operator's statement is wider: *the mesh manages
my entire machine, all four of them, as far as it makes sense* — system folders and the home
alike, the servers and the workstations from the same catalogue. And the operator refused a
distinction this effort first drew between modules that ship code and modules that ship only
declarations: *a module can have some tools, a seat implementation, some containers, a unit, a
binary, some config files — one of these, or all, or two.*
## Considered Options
1. **Keep 0040's reading and manage the environment outside the catalogue** — dotfiles in a
repository, a script that places them. Rejected: that is the predecessor's first two days, the
origin of every inherited shape [as-is 10](../03-DESIGN/00-as-is/10-module-catalogue.md)
documents, and it puts the one thing a person looks at outside the one mechanism that is
checked.
2. **Add a second kind of module for configuration** — a "config module" with files and no
process. Rejected by the operator: a kind is a distinction the manifest already makes by what
it declares, and a second kind is a second set of rules to keep in step.
3. **One definition: a module is one managed thing, described by what it declares.** Chosen.
## Decision
**1. Everything configurable on a node is declared by a module.** Services, and equally the login
manager, the display server, the window manager, the shell, the terminal, the launcher, the
notifier, the audio setup, the boot images, the package manager's configuration, the agent at the
terminal, and a folder a person works in. The test is *can it be configured on a machine*; if it
can, some module owns it. What no module declares is found and left alone, as adoption already
says of a machine ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)).
**2. A module is whatever it declares, and there are no kinds of module.** A package, files, a
container, a unit, a binary, a seat claim, tools — any one, or all. 0040's *one self-contained piece
of software* stands; its examples were services, and that was the whole of the bias. A downloads
folder with a process that tidies it, backs it up and answers questions about it is a piece of
software by 0040's own test, and so is a shell that is a package, three files and a seat.
**3. The home has no boundary of its own.** A file under the operator's home is placed and owned
the way [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §2 built it: by a
module, resolved against the account's home, owned by the account. Which files are the mesh's is
decided by what modules declare, not by a line drawn through a directory. A person's documents,
projects and history are data under [ADR 0051](0051-shared-data-is-the-operators.md) and no module
declares them.
**4. One module ships one default configuration.** No flavors. What differed between the
predecessor's four flavors of one desktop module is what [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
is for.
**5. Servers and workstations take the same catalogue.** A module declares what it needs; a
machine reports what it has; assignment refuses by name
([ADR 0161](0161-what-deserves-a-seat.md) §3). The shell, the prompt, git and the agent are universal.
A display server needs a graphical session; a window manager needs the display server held. Nothing
in a manifest says *workstation*.
## Consequences
- The catalogue grows by a family of modules that run no service. Each is still built,
registered, assigned, pushed and reported like every other, and `status` says whether a
machine has applied them.
- The account fact becomes load-bearing for every node a person uses. Today it is empty on all
four node records of this mesh; stating it is the first step of the build.
- A module that *installs* a thing is distinct from a module that *holds its role*: zsh, fish and
bash may all be installed, and one holds the login shell
([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)).
- The host's `package` shape drives the distribution's package manager only. A module whose
package is outside the distribution's repositories — the login manager in use is one — needs
either an official package or a shape the host does not have. Recorded as a gap, not decided.
- The predecessor's hooks go. What they did becomes declared state the host applies, or a verb a
seat serves ([ADR 0177](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)).
## How it is checked
| Rule | Checked by |
|---|---|
| A manifest with no container, no unit and no binary registers and resolves like any other | the catalogue's registration tests, with a package-and-files manifest |
| A file resource under the home resolves against the account and is owned by it | the controller's composition tests (to-be 29 §2, built) |
| A home-scoped module is refused on a node with no account, naming the fact | the same tests |
| A module needing a capability the machine lacks is refused by name | the resolver's tests (ADR 0161 §3) |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/00-overview.md), documents
01 and 02 — the behaviour wanted and the inventory measured.
- [ADR 0040](0040-what-a-module-is.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md),
[ADR 0051](0051-shared-data-is-the-operators.md), [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)
- [To-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the account and the home
as a placement root, built; the records for them are proposed in an open change.
@@ -0,0 +1,92 @@
---
topic: building it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
---
# 174. A node varies a module through settings and kept regions, never through an edit
## Context
[ADR 0011](0011-managed-files-are-generated-never-edited.md) says a managed file is derived and an
edit to it is overwritten without warning. The predecessor said the same and then undid it twice:
a `merge` strategy that adopted disk drift back into its database, so a local edit became the
record; and a theming layer of about 90 environment variables substituted into templates at sync
time, with tools to list and set them, so that *nearly every value was a variable* — a second
configuration language laid over the first.
The operator wants both the variation and the rule. One window-manager module with one default
configuration, and each node tweaking it; and the file carrying the wanted value rather than a
variable the file reads. Two mechanisms already exist for exactly this: a **setting**, declared by
the module and set per mesh or per node, rendered at composition
(`${setting:…}` is live in the resolver's manifest); and a **kept region**, a block in a file the
mesh writes *into* where the operator's own lines survive every push
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), used by the ssh-client module
for the operator's own `Host` blocks).
What stands in the way is [issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md):
a setting today reaches every mergeable file and every contribution of its module. Ninety theme
knobs on that mechanism would reach ninety files. The record that fixes it — a setting declared
with its type, meaning, default and the file it lands in — is proposed in an open change alongside
the container-runtime records.
## Considered Options
1. **Carry the predecessor's merge strategy.** A local edit is adopted into the node's layer.
Rejected: two writers and no arbiter, which is the option 0011 removed, and the reason a
`/model` choice was silently reverted on every node for weeks before anyone found the cause.
2. **Carry the environment-variable theming.** Rejected by the operator: the value belongs in
the file; a variable the file reads is a second place for the same fact.
3. **A per-node file override** — a whole file replaced for one node. Rejected: it is a flavor
under another name, and a module update then misses that node entirely.
4. **Settings rendered into the file, and kept regions, and nothing else.** Chosen.
## Decision
**A node varies a module in exactly two ways.**
- **A setting.** Declared by the module with a default, set for the mesh or for one node, rendered
into the file at composition. The value is in the file. Asked, the mesh lists every setting
with its effective value and where it came from.
- **A kept region.** A marked block in a file the mesh writes into, in which the operator's own
lines are kept across every push and given back when the module goes (ADR 0102).
**An edit outside a kept region is overwritten, as ADR 0011 says, and never adopted.** Nothing
reads a managed file back into the record.
**The predecessor's theme knobs become settings** of the modules whose files they render — the
window manager's colours are the window manager's settings, the bar's are the bar's — each
landing in the file that reads it and no other.
**Issue 168 is fixed before any environment module declares a setting.** A setting must name the
file it lands in; until that ships, the environment modules carry their defaults in their files
and no settings.
## Consequences
- No flavors, no per-node file copies, no environment layer. A module's definition is one set of
files; a node's difference is data in its layer, visible by asking.
- The settings record proposed alongside the container-runtime records is on the critical path
of every module with a knob, and this record depends on it shipping as proposed.
- A kept region is the only place a person edits a managed file, and the file says where it is.
The operator's own prompt customisations, aliases and window rules live there.
- What got harder: a change that is neither a setting the module declared nor the operator's own
lines has no home, and is refused by the mechanism rather than silently kept. That is the point.
## How it is checked
| Rule | Checked by |
|---|---|
| A setting reaches only the file its declaration names | the controller's settings tests, once the proposed record ships; issue 168 closes on it |
| A kept region survives a push with its content and is given back on undeclare | the host's write-into tests (ADR 0102), with a region declared by an environment module |
| An edit outside a region does not survive a push | the same tests, asserting the file equals the composed content outside the region |
| Every effective value names its source | `mesh-controller.settings` and the module's own `show-config` tool |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/01-the-intended-behaviour.md) §"One default, varied by settings, never by edits"
- [ADR 0011](0011-managed-files-are-generated-never-edited.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md),
[issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)
@@ -0,0 +1,122 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
---
# 175. One tool runtime per node serves every module's tools, on the host side
## Context
A module's tools are code the module wrote, one function behind each verb, served on the subjects
the controller issues in the module's membership
([ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)).
What *runs* that code is [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md):
a supervised process per module under the module's own account, and in the catalogue as built,
that process is a container per module per node, built on the tool runtime's base image.
Measured on the live mesh ([research 018](../01-RESEARCH/018-the-operators-machine-as-modules/03-one-tool-executor-per-node.md)):
67 module tools, each served from its module's container; the packet-filter seat's three verbs
served by a container with `NET_ADMIN` on every one of four machines, for a module that is
otherwise a package, three files and a service; and the console, a container per node, calling
everything and serving nothing. The operator's environment adds a dozen modules of the
packet-filter shape, and the operator's judgement is plain: *I would never run MCP tools inside
a container; that is a very bad design.* And: *I don't care about permissions or account per
module, that just complicates things for no good reason. Just a node-level tool executor. If a
command needs root, that's the module's concern.*
The tool runtime itself was written for this. Its own description: *the per-node process that
makes a module's tools actually serve — imports the assigned modules' compiled tool entrypoints,
each of which registers its tools as it loads; on a node the host resolves the list and starts it
like any other supervised workload.* What the catalogue did instead was build one image per module
around it.
## Considered Options
1. **Keep a process per module.** Rejected: one container per module per node for software that
is not a container, and the account-per-module invariant it exists to protect is one the
operator declines to pay for.
2. **The host executes tools itself.** Rejected: the host is a static Go binary that loads no
plugins; a module's tools are TypeScript on the SDK, and building a second SDK in Go for the
host's sake is the cost ADR 0039 refuses.
3. **One tool runtime per node, a sibling of the host, loading every assigned module's bundle.**
Chosen. It is what the runtime was written to be.
## Decision
**1. One tool runtime per node, supervised by the host, on the host side — never a container.**
The host starts it the way the launcher starts the host
([ADR 0005](0005-the-node-host.md)): a process on the machine, restarted when it dies. It holds one
bus credential, the node's. It is module-agnostic: it knows bundles and subjects, nothing of what
any module does.
**2. It serves every assigned module's tools and every held seat's verbs** on the subjects the
memberships issue. ADR 0159 and ADR 0160 are unchanged in what they say about subjects, grants
and memberships; what changes is that one process on the node subscribes to all of them instead of
one process per module. A module that runs a long-lived service of its own — a daemon, a
container — keeps it; this record is about tools.
**3. A module brings its tools as a bundle**, the artifact kind the catalogue already has for
interpreted code, built by the pipeline and delivered to the node by the host as it delivers any
artifact. Never an image. The runtime loads each bundle as the membership names it, and a push
that adds or replaces a bundle reaches a running runtime as a reload.
**4. Root is the module's concern.** A tool that must change the packet filter or rebuild boot
images escalates itself. The runtime does not run as root for everyone's sake; the caller does not
know and need not.
**5. Any node may call any tool on any node.** The runtime's credential may call everything, as
the console's already may. A per-module calling grant is not kept.
**6. The console is this runtime's serving mode, renamed.** [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md)
stands in substance — a module assigned per node, MCP on the machine's loopback, the machine's
login is the authority — and changes in form: host-side, serving as well as calling, and named for
what it is: **node tools**. The mesh's own verbs stay with the controller
([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)); a mesh-scoped seat's verbs
run on the node that holds it ([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)).
**Where ADR 0047 and ADR 0150 say a module's tools are served by the module's own process under
the module's own account, read this record.** Everything else they decided stands: a tool is served
on its own subject, only the module that serves it answers, a module's long-lived processes are the
machine's to supervise. The invariant 0150 kept — one account per module — no longer holds for
tools, and the reason is stated above: every tool is callable from everywhere by decision 5, so the
account no longer scopes anything a caller cannot already reach.
## Consequences
- The packet-filter module's container goes; its verbs run on the host side and escalate as they
need. [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md) §3's container capability is moot
for it.
- The tool runtime's base image stays the way a module's *service* may be built; it is no longer
the way tools reach a node.
- The node tools runtime needs an interpreter on the machine. The module that is the runtime
declares it as a package.
- The container-runtime seat proposed in an open change says its holder *runs as a supervised
process and serves the verbs locally to the host and on the bus*. A supervised process serving
verbs is what this runtime is; whether that holder keeps a process of its own or serves through
the runtime is for that record's build to say.
- What got harder: one process carries every module's tool code on a node, so one module's
faulty bundle can take down the node's tools. The runtime loads each bundle guarded and names
the one that failed; the others serve.
## How it is checked
| Rule | Checked by |
|---|---|
| The runtime loads every bundle its memberships name and serves each tool on its subject | the runtime's tests against a real bus: two bundles, three tools, each answers |
| A bundle that fails to load is named and the others serve | the same tests, with one bundle that throws on load |
| The host supervises the runtime and restarts it | the host's tests over the launcher's shape |
| A push that replaces a bundle reloads it without a restart | the runtime's tests: a bundle replaced on disk, the membership re-read, the new tool answers |
| No module in the catalogue declares a container whose only purpose is tools | a catalogue check: a manifest with `tools` and an image artifact built on the tool runtime's base is refused once the runtime is live |
| Live | `login-shell.execute@<node>` answers on every node from the node tools runtime; `docker ps` shows no per-module tool container |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/03-one-tool-executor-per-node.md)
- [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md), [ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md),
[ADR 0152](0152-the-operators-surface-is-a-module-the-console.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md),
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)
- [To-be 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md), [to-be 34](../03-DESIGN/01-to-be/34-the-console.md)
@@ -0,0 +1,81 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
---
# 176. The login shell is a node seat held by one shell module, and `execute` is its contract
## Context
[ADR 0040](0040-what-a-module-is.md) names the shell as its example of a *shared* seat: bash, zsh
and fish all join `shell`, and one may be default. The operator's reading is sharper, and it
matches [ADR 0126](0126-a-module-declares-its-own-seats.md) better: *installing* a shell is
installing software, and several may be installed; *holding* the seat is being the login shell,
which a node has exactly one of. A definition says which seats a module can hold; the assignment
says which it does.
A seat carries the tools its holder must serve ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)),
and [to-be 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) leaves which verbs each
seat serves as a decision per seat, taken slowly. This is the first seat of the operator's
environment, and the one every node has.
## Considered Options
1. **A shared `shell` seat with a default**, as 0040's example reads. Rejected: *default* is a
second concept beside *holder* for the same fact, and the `user` shape already makes the
login shell declared state ([to-be 05](../03-DESIGN/01-to-be/05-the-node-host.md)).
2. **No seat; each shell module sets the login shell for itself.** Rejected: two assigned shell
modules would fight over `chsh`, and nothing would say which won.
3. **An exclusive node-scoped seat, `login-shell`, declared by the shell modules, held by one
per node.** Chosen.
## Decision
**1. `login-shell` is a node-scoped seat declared by the shell modules.** zsh, fish and bash each
declare that they can hold it; a node's assignment says which does; the controller refuses a
second holder by name as for every seat. A shell module that is assigned without holding the seat
is installed and nothing more.
**2. Holding the seat sets the account's login shell.** The holder's declaration carries the
`user` shape with the shell it provides, so the login shell is declared state the host applies and
gives back when the holding moves — `chsh` stops being a hook.
**3. The seat's contract is `execute`.** One verb, one argument, the command, run on the node the
seat is scoped to as the operator account, answering with what it printed and how it exited.
Every holder serves it; a holder may serve its own tools beside it
([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md) §2) — show the rendered configuration, list
the plugins, set a prompt value.
**4. Any node may call it on any node.** The grant is the node tools runtime's
([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) §5):
*run `uptime` on every node* is five calls to one verb.
## Consequences
- The first environment module is a shell: a package, files under the home owned by the account,
a seat declaration and claim, a `user` shape, and one tool. It proves the whole pattern on every
node, servers included, before anything graphical is written.
- ADR 0040's shell example is read as *installed is not holding*; a dated note in that record says
so. Its decision is untouched.
- `execute` is a shell on every machine, addressed over the bus. That is the point, and it is
the widest verb the mesh serves; it exists because the operator decided every node may call
every tool, and this record does not narrow that.
## How it is checked
| Rule | Checked by |
|---|---|
| Two shell modules assigned to one node, one holding: one `user` shape in the declaration, naming the holder's shell | the controller's composition tests |
| A second claimant is refused by name | the catalogue's seat tests |
| `execute` runs as the account and answers output and exit status | the module's tool tests over a fake runner, and live on every node |
| The seat's verb appears with its scope and machine in the node tools listing | the runtime's tests (to-be 33 §4) |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md)
- [ADR 0040](0040-what-a-module-is.md), [ADR 0126](0126-a-module-declares-its-own-seats.md),
[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)
@@ -0,0 +1,80 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
---
# 177. A unit may be user-scoped, and the service manager is a node seat whose holder answers for the units
## Context
The host's `service` shape puts a system unit into a state. It has no user scope.
[To-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) states the gap: *a
workstation's per-user daemons have no form the mesh can send.* Four of the predecessor's
environment modules ship user units — the desktop's reload watcher and bar watchdog, the audio
module's masks, the power module's memory guard, the thermal daemon's profile switcher — and the
predecessor needed a hook to enable them because *shipping a unit file does not run it*; one unit
was deployed for months and ran on one machine only.
[ADR 0040](0040-what-a-module-is.md) says the host hardcodes no supervisor, and a swappable
machine mechanism is a module implementing a capability — which is what the nftables module is for
the packet filter ([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)). The service manager is
reported today as a capability, `service-manager`, and held by nobody. The operator's proposal: a
systemd module that holds the seat and serves the tools about units, system and user.
## Considered Options
1. **Keep user units as a module concern** — each module runs `systemctl --user` in a hook.
Rejected: that is the hook that silently never ran, and an action over the link is refused.
2. **The service-manager module applies units** on behalf of others, as a provision. Rejected by
the operator: provisioning is for resources a provider creates for a consumer; a unit is
declared state the host applies, as every resource is.
3. **The host's `service` shape gains a user scope; a systemd module holds the service-manager
seat and serves the verbs about units.** Chosen.
## Decision
**1. The `service` shape gains `scope`: `system` (the default) or `user`.** A user-scoped unit
is applied as the operator account through the account's own service manager: enabled, started,
stopped, reloaded on its triggers, exactly as a system unit is, and refused on a node with no
account, naming the fact. The host applies it; no module does.
**2. `node-service-manager` is a seat of the mesh's own, node-scoped**, seeded by the controller
under this record, as ADR 0121 requires of a `node-*` name. The `systemd` module claims it and is
assigned to every machine whose profile reports `service-manager`.
**3. The seat's verbs answer for every unit on the machine**, each taking an optional `scope`:
`units`, `status`, `start`, `stop`, `restart`, `enable`, `disable`, `journal`. The host applies what
is declared; the holder answers questions and operator acts about it, and says, for a mesh-held
unit, that the host will restore what its declaration says.
## Consequences
- The host's vocabulary grows by one field on one shape, asserted by its count test
([to-be 05](../03-DESIGN/01-to-be/05-the-node-host.md)); an older host refuses a declaration
carrying it, so the host rolls before the first module that uses it.
- The predecessor's four user-unit modules become declarable without a hook.
- The seat's holder is the first system seat held by a module that runs nothing of its own: its
verbs are served by the node tools runtime ([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
- What got harder: `journal` and `status` on a user unit need the account's manager reachable
from the runtime's process, which runs as the node's account; the holder's tool escalates or
switches user as it needs, which is ADR 0175 §4 applied.
## How it is checked
| Rule | Checked by |
|---|---|
| A `service` with `scope: user` is enabled and started under the account, and refused with no account | the host's tests with a fake service manager |
| The seat declares its verbs; a claim serving fewer is refused by name | the catalogue's seat tests |
| The verbs act on a named unit in the named scope and name the unit's holder when the mesh declares it | the module's tests over a fake runner |
| Live | the desktop's reload watcher declared `scope: user` on a workstation; `node-service-manager.status@<node>` reports it active |
## References
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md)
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md),
[ADR 0040](0040-what-a-module-is.md), [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)
- [To-be 05](../03-DESIGN/01-to-be/05-the-node-host.md), [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)
@@ -78,6 +78,13 @@ now logs the plain request too, with the asking address last, as its own jail's
start. The fail2ban module claims them and gains a runtime — a tool server whose image carries the
fail2ban client, with the daemon's socket shared in from the machine, and nothing else of the
machine. The daemon stays the machine's; what runs in the container is only the client.
- **That runtime is the shape the catalogue has today, and it is on its way out.**
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
accepted the same day as this record, replaces a tool container per module with one tool runtime
per node on the host side, taking each module's tools as a bundle. Nothing here depends on the
container: the verbs, the client that speaks to the daemon over its socket, and the jails are the
same code under either. This module converts with the packet filter's, whose runtime that record
names, and the socket it needs becomes the node runtime's to reach rather than a mount of its own.
- The host's container vocabulary grows by `logging`; an older host refuses a declaration that carries
it, so the host rolls before the modules. Three containers are recreated once, when their modules
are pushed with the field: the mail front end, the forge and the proxy — each a moment's outage.
@@ -103,5 +110,5 @@ now logs the plain request too, with the asking address last, as its own jail's
## References
- [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md), [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
- [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md), [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
- [Design 31 — A module declares its fail2ban jail](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md), [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md), [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md)
@@ -7,7 +7,9 @@ reconstructed: false
extends: 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
---
# 175. The found front end is uninstalled once a machine is converged
# 180. The found front end is uninstalled once a machine is converged
> **Renumbered 2026-10-02.** Written and merged as 0175 while another record already held that number on main (one tool runtime per node, merged minutes earlier); `cycle.py` refused main. The branch that lands last renumbers: 0178 and 0179 are claimed by open changes, so this is 0180. Nothing cited it by number.
## Context
@@ -0,0 +1,118 @@
---
topic: what runs on it
status: accepted
date: 2026-09-27
deciders: jochen
reconstructed: true
extends: 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
---
# 181. The operator account is a node fact, and a home is a placement root
*Reconstructed. The controller shipped this on 2026-09-27 and
[to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) recorded it as built without a
decision behind it. This record states what was decided, from the code and the design, and adds the
two rules the code left implicit — what an empty account means for a module, and that the account is
stated rather than discovered. Written 2026-10-02.*
## Context
[ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) took every host path out of a
module definition and gave a module's *system* data a place: a directory the mesh resolves under the
node's root, owned by the module. It said nothing about the other half of a filesystem — the files
that belong under a person's home and are owned by that person. The predecessor wrote several of
those: the ssh client configuration, the shell's configuration, an agent's instruction files. It knew
whose home it was writing into because each of its node records carried a login name. The mesh took
the machine facts over and dropped the human one.
The loss was found the ordinary way: `ssh <node>` logged into the home-server under the workstation's
own login name, because nothing in the mesh said the home-server's account was a different one
([to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md),
[issue 172](../04-ISSUES/172-the-ssh-client-block-matches-one-spelling-of-a-machine/00-report.md)).
What the controller does since 2026-09-27: a node record carries an operator account and, optionally,
its home; the account and its home are machine facts a definition may name in a resource's path, owner
and content; a roster file may say it lives under the home, and is then rendered per node, placed under
that node's account's home, owned by the account, and left out on a node with no account. On
2026-10-02 **all four nodes of the live mesh carry an empty account**: the fact exists and nobody has
stated it, so no home-scoped resource can land anywhere yet.
## Considered Options
1. **The definition names the login.** `owner: <name>` in the module. Rejected: it is the installation
written into a definition, which ADR 0112 forbids and
[ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md) checks for, and it is
wrong on the first machine whose login differs — which is exactly the machine that surfaced this.
2. **The host discovers the account.** The first non-system user, or whoever ran the enrolment.
Rejected: a guess. A shared machine has several people on it, a server may have none, and a host
deciding whose files these are is a decision the mesh then cannot see, state or correct.
3. **The account is a fact the operator states on the node record, and the home is derived from it
unless stated.** Chosen.
## Decision
**A node has an operator account: the login name of the person who works on it.** It is stated by the
operator on the node record, the way a node's address or mode is held there, and it is empty for a
machine nobody logs into. Empty is a real state, not a missing value. The mesh holds the fact because
everything below derives from it, and because it is precisely the fact that was lost when the
predecessor's records were not carried over.
**The account's home is derived unless stated.** The superuser's home for the superuser, the
distribution's conventional per-user home otherwise; a node whose account lives elsewhere states its
home. One place computes the default, so a fact and the record cannot disagree about it.
**A resource may be placed under the home, owned by the account.** This is ADR 0112's move one level
over: as a module's system directory is resolved under the node's root, a file under a person's home is
resolved against the account's home, and owned by the account rather than by root or a module's own
account. A definition names the account and its home as machine facts, never as a path; a roster fact
may say it is a home file and is then placed and owned the same way. The controller resolves both at
composition, and the host chowns what it creates.
**A node with no account cannot carry a home-scoped resource, and says so.** A roster fact that lives
under the home is left out of that node's declaration rather than written to nowhere. A resource naming
the account fact on such a node is refused at composition, naming the fact the machine does not have.
A module that writes a person's files is thereby unassignable to a machine with no person on it, which
is the right refusal.
**One account per node is what this record decides.** Several people on one machine is left open, with
the constraint that allowing it must not force the common case — one workstation, one person — to name
anything.
## Consequences
- **The operator states the account before any home-scoped module lands.** Today none is stated, so the
first assignment of such a module begins with four node records.
- The roster carries each node's account, so a composed ssh configuration logs in as the right person
on every machine — the gap that surfaced this, closed by the same fact.
- A family of modules becomes writable: everything the predecessor placed under a home — ssh client,
shell, the agent's instruction files — is now a module naming a fact rather than a path
([to-be 29 §2](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)).
- **What got harder:** a definition cannot say "my user's home" without the mesh knowing who the user
is, so a module of this family is refused on a freshly enrolled machine until a person is named on
it. That is a prompt, not an obstacle.
- **Not decided here:** several accounts per node; a service unit running as the account rather than
as root or a module; a one-off step run as the account. Each is a record of its own.
## How it is checked
| Rule | Checked by |
|---|---|
| A resource's path and owner resolve the account and its home | controller tests on machine-fact resolution: a file naming the account facts lands under the account's home, owned by the account |
| The home is derived unless stated | a controller test: the superuser's home for the superuser, the conventional home otherwise, the stated home when one is stored |
| A home roster fact is left out on a node with no account | a controller test on roster composition: the file is absent from that node's declaration and present on a node with an account |
| A resource naming the account on a node with no account is refused by name | a controller test on machine-fact resolution: the refusal names `account` and lists the facts the machine does have |
| No definition names a home path | ADR 0112's catalogue test on host paths, which a `/home` or `/root` literal fails |
## References
- [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the design this record
gives a foundation to, and its "what has shipped" section
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — the system-path placement
this mirrors; [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md) — why
a login name may not be in a definition
- [ADR 0120](0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster fact a home file
may be
- [ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) — what
the mesh may and may not do inside the home this record lets it reach
- mesh-controller `internal/inventory/nodes.go` (the account and its home on the node record),
`internal/catalogue/machine_into_files.go` and `roster.go` (resolution and the home fact)
@@ -0,0 +1,117 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md
---
# 182. Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found
## Context
[ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) lets a module
place files under a person's home. A home is unlike any directory the mesh has written into so far:
it is shared with the person, and with every program the person runs. The agent's configuration
directory on the laptop makes the point. On 2026-10-02 it holds thirty entries. The predecessor placed
five of them (an instruction file, a conventions rule, a settings file it merged into, two skills); a
sibling module placed a sixth (the node's identity rule). The agent itself writes the other
twenty-four: its settings, its credentials, its history, the memory of every project it has worked in,
its plugins, its session logs. Several of those are what [to-be 15](../03-DESIGN/01-to-be/15-the-agent-session.md)
calls memory *written by the session itself and declared by nobody*: a mechanism that regenerated the
directory would erase a season of it, silently, while reporting success.
The predecessor's own module recorded the hazard in the other direction. Its settings file was first
shipped as *replace*, and every `/model` choice a person made inside a session was reverted to the
template's value on the next synchronisation — on every node, indefinitely, with no indication why. It
was changed to *merge*, and the comment explaining why is still in its manifest.
**And the generator is gone while its output stayed.** The predecessor was retired from the laptop on
2026-10-01. Its six files are still on both workstations, with their content telling every session to
use tools that no longer exist. Nothing owns them; nothing will ever rewrite or remove them.
[To-be 29 §3](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) drew the line for one directory,
`~/.ssh`: the mesh owns the directory and the files it places; it holds the person's private keys and
personal drop-ins as found. That was argued from the lockout `~/.ssh` can cause. The argument here is
the same shape with a different stake — the person's work rather than the person's way in — and it has
to hold for every directory the family of home-scoped modules will touch, so it is a rule, not a
section.
## Considered Options
1. **The module owns the directory whole**, regenerating it from the definition. Rejected: it destroys
the memory, history and local settings the agent writes for itself, which is the failure to-be 15
names and the predecessor's settings file demonstrated at small scale.
2. **The module owns only the files it names, and nothing about the directory.** Rejected: *owning one
file beside foreign ones is not owning anything* (to-be 29). The directory must exist, with the right
owner and mode, before the tool first runs on a fresh machine; and a credentials file in a
world-readable directory is a credentials file in the wrong directory.
3. **The module owns the directory and the files it places; a file the tool writes for itself is
written into, never over; everything else is held as found.** Chosen.
## Decision
**A home-scoped module owns the directory it declares: its existence, owner and mode.** The host creates
it if absent, owned by the account, and never removes it while it holds anything
([ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)). Inside it, every path the module touches
is in exactly one of four classes, and **the class is visible in the definition from the shape
declared**, not inferred from what happened to be on disk:
| class | declared as | the host's rule |
|---|---|---|
| **owned** | a file with content, or a roster fact | written whole, regenerated, removed when undeclared; a file found there with no record of the mesh making it is kept once before it is written over ([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)) |
| **written into** | a file written *into* a structured document | only the keys the definition names are set, every other key is kept, and each set key is given back when undeclared (ADR 0102). The key list is the module's and is short |
| **written by the module's own process** | nothing the host applies: the module's code writes it from what it was handed | the file's content is never a declared file's content, because a declaration travels in the clear on the bus and the host records it; the module's code writes it, owned by the account, atomically. [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) says how for a credential |
| **found** | nothing | never read, never rewritten, never removed. The person's memory, history, projects, local settings, their own rules and skills |
**A file the tool writes for itself is written into, never over.** The agent's settings file and its
own state file are the tool's; the mesh has one or two facts to state in each. Setting those keys and
nothing else is what lets a person's `/model` choice survive a push, and what lets the mesh's keys be
taken back cleanly when the module goes.
**A predecessor's output is found.** A file placed by a generator that no longer exists is, to the
mesh, a file it has no record of making. Where the successor module keeps the path, declaring it
*adopts* it: the host keeps the original once and writes the mesh's. Where the successor does not keep
the path, the mesh does not remove the file, because it removes nothing it did not make; **the operator
removes it, once**, and the module's definition names those paths in its own documentation so the step
is not forgotten. This is the first instance of the one-off setup step to-be 29 leaves open, and the
rule chosen for it is that it is a person's act, listed, not a module's.
**The rule is the family's.** An ssh client module, a shell module, an agent module each declare their
directory and classify their paths this way. A module that cannot say which class a path is in has not
finished its definition.
## Consequences
- A person's work under their home survives every push and every unassign. The mesh's own files come
and go with the module; the mesh's keys in the tool's files come and go with it; the directory stays.
- **Stale files survive too.** Two workstations keep three predecessor files each until a person removes
them — a visible cost, accepted over a mesh that deletes under a person's home. A module author who
renames one of the mesh's own files has the ordinary path: the old resource id is undeclared and the
host removes what it made ([ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md)).
- A module's definition is longer by a classification, and a reviewer has one more question per path.
That is the point: *which parts are managed must be explicit rather than inferred* (to-be 15).
- **What got harder:** a module cannot seed a person's preference once and leave it. A seeded file
([ADR 0087](0087-a-seeded-file-is-created-once.md)) is the shape for that, and it is available to
this family unchanged; what is refused is a seed the module later wants to change, because what grew
in it is the person's.
## How it is checked
| Rule | Checked by |
|---|---|
| The directory is created owned by the account and kept when the module goes | host tests of a directory resource with an owner (ADR 0051's and 0118's), and the family's lab check below |
| An owned file found with no record is kept once, then written | host tests of ADR 0102's kept-original rule |
| Only the declared keys of a written-into file change, and are given back | host tests of ADR 0102: declared keys set, the rest kept, restored when undeclared |
| Nothing found is touched | the family's lab check: a machine with a seeded home holding a person's file beside a predecessor's; after apply the person's file is byte-identical, the predecessor's is kept as the original, the mesh's keys are set and the person's keys in the same file remain; after unassign the mesh's files are gone, the keys are restored, the person's files are untouched and the directory stands |
| Every path a home-scoped module touches is classified | a catalogue review rule for this family: each path is a directory, a file, a file written into, a secret-and-step, or absent — the first module written to it is [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) |
## References
- [to-be 29 §3](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the same boundary drawn for `~/.ssh`
- [to-be 15](../03-DESIGN/01-to-be/15-the-agent-session.md) — a session's memory is declared by nobody
- [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0087](0087-a-seeded-file-is-created-once.md),
[ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md), [ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md) — the mechanics each class rests on
- [ADR 0051](0051-shared-data-is-the-operators.md) — the third case the host had no word for: what it neither made nor configured
- the predecessor's `claude-code` module manifest, whose comment on `strategy: merge` records the reverted `/model` choice
@@ -0,0 +1,163 @@
---
topic: what runs on it
status: accepted
date: 2026-10-02
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0024-model-access-is-a-provision.md
---
# 183. The Anthropic licence manager is a module holding a seat; it hands each node's agent its token over the bus, sealed; the controller and the host have no part
## Context
**The operator's stance, set on 2026-10-02 and sharpened during the day.** The controller has no part in
the agent module. The host is module-agnostic: it knows no vendor, no agent, no path under a home. The
agent module owns its own files. And there must be a *real* licence manager — a module that doles out
the correct licence in every situation the mesh has: two subscription accounts and one API key today,
used by a person's interactive agent on each workstation, by the mesh's own sessions, and by workers.
**What the predecessor built, read from its code the same day.** Two modules, split after an incident.
A *manager* on exactly one node held every account's full OAuth grant encrypted, rotated each grant
under a per-licence lease on a cadence and an expiry floor, published each rotation over its bus with
the tokens encrypted, collected the vendor's usage figures per licence, and alerted once a day on
repeated failure or on a refresh token within three days of its own expiry. A *consumer* on every node
was the single writer of the agent's credentials file: it applied a published rotation, stripped the
refresh token so a node could never rotate, pulled when stale, refused a stale grant by comparing
expiries within one lineage, and mirrored a local login back to the manager only after checking the
account's identity against the licence's record — because an unchecked mirror had once written one
account's grant into another's row and published it mesh-wide. Three **touchpoints** with fallbacks: the
node's interactive agent; the mesh's own sessions on the node, falling back to the node's licence; a
worker's own account, falling back to the node's, and refusing to spawn when assigned a licence that
could not be served. The split exists because four nodes refreshing one grant destroyed it: an OAuth
refresh rotates the refresh token, and the predecessor's own code records both that a reused token
killed a licence and that a malformed client id was once misdiagnosed as the same fault. **Whether a
refresh token is single-use is not documented by the vendor**; the predecessor treated it as so, and
this record keeps one rotation source for that reason while leaving the fact to be measured.
**What the mesh has.** [ADR 0050](0050-model-access-is-vendor-agnostic.md) put a per-vendor adapter
inside the controller's licences context, with the carve-out that the manager node holds the refresh
token readably; the catalogue has a manager and a consumer module built on it, assigned to nothing. The
controller's licence commands are not seat verbs and cannot be asked for through the console
([to-be 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)). `model-access` is a vendor-blind
provision ([ADR 0024](0024-model-access-is-a-provision.md)), and the operator's judgement is that the
agent is not a vendor-blind consumer: it is coupled to an Anthropic subscription grant and nothing else,
so a name that hides the vendor misdescribes the coupling
([ADR 0027](0027-a-provision-names-what-the-consumer-is-coupled-to.md)).
**The bus's rule for a secret** ([to-be 32 §10](../03-DESIGN/01-to-be/32-what-a-module-declares.md)): the
bus is not trusted with one; a secret travels sealed to its recipient, on core request/reply, never
through a stream that persists it.
## Considered Options
1. **Keep the lifecycle in the controller** ([ADR 0050](0050-model-access-is-vendor-agnostic.md) as
built), and make the agent module a consumer of `model-access` delivered by the host as a sealed
file. Rejected by the operator: the controller and the host would both carry a part of an
Anthropic-specific mechanism, and the agent's coupling is misnamed.
2. **The manager delivers each short-lived token through the vault**, as a backend-issued secret the
vault provides to each consumer ([ADR 0113](0113-the-vault-makes-every-secret.md)). Rejected: every
hourly rotation becomes a vault delivery, a composition and a push to every node, and the host
ends up writing a vendor's credential as a file — the module-agnostic host, carrying a vendor's
traffic.
3. **A seat-holding manager module that talks to the agent module on every node over the bus.**
Chosen.
## Decision
**The Anthropic licence manager is a module, `claude-licence-manager`, holding the mesh-scoped seat
`anthropic-licence-manager`.** The seat's contract is the licence verbs: list the licences and their
health, list the bindings, bind or switch a consumer, release one, refresh now, read usage, adopt a
grant, register a node's key, answer a consumer's current token. One holder, on a node the operator
assigns, is what makes rotation happen once ([ADR 0126](0126-a-module-declares-its-own-seats.md),
[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)). The seat is named for the vendor,
because what it manages is one vendor's grants and nothing else is coupled to it. The vendor-blind
`model-access` provision stands for the consumers that do not care which vendor answers; the agent is
not among them.
**The manager owns the licences.** The records, the grants, the bindings per touchpoint, the usage
readings and the audit of every switch live in the manager's own store, not in the controller's
licences context, which keeps only what it already serves to vendor-blind consumers. The manager is the
one rotation source: it alone calls the vendor's token endpoint, under a lease per licence, on an expiry
floor and a cadence it declares as a setting.
**The long-lived grants are encrypted at rest with a key the vault made for the manager.** The vault
keeps custody of that one key as the manager's own secret ([ADR 0113](0113-the-vault-makes-every-secret.md));
the grants themselves — a refresh token per subscription account, the API key — are the manager's
rows, readable only by it. This is [ADR 0050](0050-model-access-is-vendor-agnostic.md)'s carve-out,
moved with the manager: *one module, one node, the long-lived grants only.*
**The short-lived tokens travel module to module, sealed, on request/reply.** The agent module on each
node makes a keypair of its own when it first runs — a private key made where it is used, never leaving
([ADR 0113](0113-the-vault-makes-every-secret.md)) — and registers its public half with the seat. The
manager hands a node its token by calling that node's agent module (`<module>.<tool>@<node>`,
[ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)) with the token
sealed to that key, and the module answers *applied* or *refused* and why. An agent module that starts,
or finds its token near expiry, asks the seat for its current token the same way. **A token is never
published as an event**: what the manager emits — rotated, switched, failing, usage read — names the
licence and nothing secret, and the audit logger records it. This is a second channel for a secret
beside the vault's, and it is bounded as 0050's carve-out is: this vendor, tokens that live hours, sealed
to one recipient, request/reply only.
**The agent module alone writes what the agent reads.** For a subscription licence it writes the
agent's credentials file under the operator's home, as the operator, access-token-only, atomically. For
the API-key licence it serves the key through the agent's own key-helper setting, so nothing is written
under the home at all. For the mesh's own sessions and workers on that node, it is the local source of
their token. **The host delivers the module's package and its state directory and knows nothing else**:
no path under the home, no vendor, no file shape.
**A binding is explicit, and a switch is a reaction.** Every consumer — a node's interactive agent, the
mesh's session on a node, a worker — is bound to a licence by the operator through the seat's verb, with
the predecessor's fallbacks: a session inherits its node's licence, a worker inherits its node's, and a
worker assigned a licence that cannot be served is refused rather than lent another. Exhaustion is
observed and warned about once per crossing of a declared threshold; moving a consumer to another
licence is a person's act through the seat's verb, as [ADR 0024](0024-model-access-is-a-provision.md)
says, and the declaration language grows no conditional. An automated policy is not decided here.
**A login is attributed only to the account it belongs to.** When a person logs in on a node, the
agent module reads the account's identity from the agent's own state and offers the grant to the
manager sealed to the manager's key; the manager adopts it only when the identity matches the licence
the node is bound to, and refuses with a notification otherwise.
## Consequences
- One module decides which licence every consumer gets, one module writes what each agent reads, and
neither the controller nor the host carries a word of the vendor.
- **A second sealed channel exists** beside the vault's, bounded as stated. A record that widens it to
another vendor or a longer-lived secret is a new decision, not an application of this one.
- The catalogue's `anthropic-manager` and `anthropic-consumer` modules, built on ADR 0050's placement,
are retired once the manager runs; the controller's licences context stops holding Anthropic licences.
- The console lists the seat's verbs, so a person switches a licence in a sentence, and the controller
gains no `licence` verb.
- **What got harder:** a manager that is down leaves every node on its last token until it expires;
the agent module keeps the last token and says so. And a node whose agent module has not registered
its key cannot be handed a token, which the manager reports by name.
- Every interactive session on a machine shares the node's one agent directory, and so its licence;
twenty sessions share it as one does. A consumer with a licence of its own on the same machine is a
worker running from a home of its own with its own agent directory — the worker touchpoint above, for
when workers exist ([ADR 0003](0003-agents-are-persistent-employees.md)); the predecessor ran its
agents that way.
- **Not decided here:** an automated switch on exhaustion; whether a refresh token is single-use, to be
measured in the lab.
## How it is checked
| Rule | Checked by |
|---|---|
| Only the seat's holder calls the vendor's token endpoint | a catalogue test: no module but the manager names it; the manager's refresh runs under a lease per licence, tested with two concurrent runs |
| A token crosses the bus only sealed, only on request/reply | a bus test: every message the manager publishes as an event carries no token; the hand-over is a request whose payload opens only with the receiving module's key |
| The agent module's private key never leaves the node | the per-key test of ADR 0113, extended to this module's key |
| The host writes nothing under a home and names no vendor | a catalogue test on the agent module's definition: no file resource under a home, no vendor word in anything the host applies |
| A grant is attributed only to a matching identity | a manager test: a grant whose account identity differs from the bound licence's is refused and a notification emitted |
| An unservable binding refuses rather than lends | a manager test: a worker bound to a dead licence is answered with a refusal, never another licence's token |
| A switch through the console changes the token on the node and nothing in the answer is a token | a live check on one workstation |
## References
- [ADR 0024](0024-model-access-is-a-provision.md), [ADR 0050](0050-model-access-is-vendor-agnostic.md) — the licence as a named thing, the carve-out this moves with the manager
- [ADR 0027](0027-a-provision-names-what-the-consumer-is-coupled-to.md) — why the seat is named for the vendor
- [ADR 0113](0113-the-vault-makes-every-secret.md) — the vault's custody of the manager's key, and the exception stated here
- [ADR 0126](0126-a-module-declares-its-own-seats.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md) — a module's seat, its verbs, a call addressed to one machine
- [to-be 32 §10](../03-DESIGN/01-to-be/32-what-a-module-declares.md) — a secret on the bus
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md), [to-be 39](../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md) — the two modules
- the predecessor's `claude-licences` and `claude-code` modules, read 2026-10-02: the lease, the floor, the lineage comparison, the identity guard, the touchpoints
+9 -1
View File
@@ -182,8 +182,8 @@ python3 00-META/checks/index.py fail if stale
- **0169** — [A machine joins through the tunnel, and the bus is never public](0169-a-machine-joins-through-the-tunnel-and-the-bus-is-never-public.md)
- **0170** — [The firewall seat serves its verbs, and a foreign rule set is removed through one of them](0170-the-firewall-seat-serves-its-verbs.md)
- **0172** — [The lab is a module, and runs a bed when the mesh asks](0172-the-lab-is-a-module-and-runs-a-bed-when-the-mesh-asks.md)
- **0175** — [The found front end is uninstalled once a machine is converged](0175-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md)
- **0179** — [The intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)
- **0180** — [The found front end is uninstalled once a machine is converged](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md)
### Its tiers, from the bottom up
@@ -274,6 +274,13 @@ python3 00-META/checks/index.py fail if stale
- **0150** — [A module's own code runs as supervised processes under the module's one account](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)
- **0152** — [The operator's surface is a module the mesh assigns: the console](0152-the-operators-surface-is-a-module-the-console.md)
- **0155** — [A definition names no installation: how that is checked, and the three ways a value that did gets out](0155-a-definition-names-no-installation-and-how-that-is-checked.md)
- **0173** — [The operator's machine is the mesh's, and a module is whatever it declares](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md)
- **0175** — [One tool runtime per node serves every module's tools, on the host side](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
- **0176** — [The login shell is a node seat held by one shell module, and `execute` is its contract](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
- **0177** — [A unit may be user-scoped, and the service manager is a node seat whose holder answers for the units](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)
- **0181** — [The operator account is a node fact, and a home is a placement root](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)
- **0182** — [Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
- **0183** — [The Anthropic licence manager is a module holding a seat; it hands each node's agent its token over the bus, sealed; the controller and the host have no part](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
### How it is built
@@ -295,6 +302,7 @@ python3 00-META/checks/index.py fail if stale
- **0107** — [Persistent data is a directory bind, never a named volume](0107-persistent-data-is-a-directory-bind-never-a-named-volume.md)
- **0111** — [A build source is on the mesh's git seat, or it is an external repository](0111-a-build-source-is-on-the-git-seat-or-external.md)
- **0149** — [The live mesh is the test bed](0149-the-live-mesh-is-the-test-bed.md)
- **0174** — [A node varies a module through settings and kept regions, never through an edit](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
### How it is checked