Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d57289e049 | ||
|
|
d4a2f99ab5 | ||
|
|
a9f91fdd0c | ||
|
|
131a5e4714 | ||
|
|
329a24fdae | ||
|
|
7f72f3b79a | ||
|
|
114a71f36f | ||
|
|
0f407417f3 | ||
|
|
4af731df19 | ||
|
|
7b1dabbce0 | ||
|
|
2caa5e827b | ||
|
|
d0d5799884 | ||
|
|
9ba4de5557 |
@@ -9,6 +9,11 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
|||||||
|
|
||||||
- **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is
|
- **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is
|
||||||
just a machine that has joined; being one implies nothing about what it runs.
|
just a machine that has joined; being one implies nothing about what it runs.
|
||||||
|
- **operator account** — the login name of the person who works on a node, stated on the node
|
||||||
|
record; empty for a machine nobody logs into. Everything the mesh places under a person's home is
|
||||||
|
resolved against this account's home and owned by it
|
||||||
|
([ADR 0181](../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
|
||||||
|
Not "the user" (ambiguous with a module's own account) and not a name a definition carries.
|
||||||
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one
|
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one
|
||||||
per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs
|
per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs
|
||||||
the controller (and, today, the foundation). Lose it and the other nodes keep running what they
|
the controller (and, today, the foundation). Lose it and the other nodes keep running what they
|
||||||
|
|||||||
@@ -204,3 +204,14 @@ only, the refresh token only, the manager node only.**
|
|||||||
record extends, amended to describe the adapter generalisation.
|
record extends, amended to describe the adapter generalisation.
|
||||||
- The read-only vendor-agnostic analysis, 2026-09-05 (code workspace) — the inventory and the decisions
|
- The read-only vendor-agnostic analysis, 2026-09-05 (code workspace) — the inventory and the decisions
|
||||||
taken on the open questions this record encodes.
|
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
|
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.
|
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.
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
topic: the mesh
|
||||||
|
status: accepted
|
||||||
|
date: 2026-10-02
|
||||||
|
deciders: jochen
|
||||||
|
reconstructed: false
|
||||||
|
extends: 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 179. The intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Read on the control node on 2026-10-02, the day the machines were confirmed filtered by the mesh
|
||||||
|
alone ([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)): the intrusion
|
||||||
|
prevention watched one door. Its two jails read the ssh daemon's journal and its own log, banned
|
||||||
|
five failures in ten minutes for ten minutes, and in a day had seen twelve thousand failed logins
|
||||||
|
from three hundred addresses and banned none of the busiest, which paced themselves at one try every
|
||||||
|
ten minutes. The mail submission port took a hundred and sixty password guesses in the same day from
|
||||||
|
thirty-eight addresses with no jail reading it at all; the forge and the public proxy had no jail
|
||||||
|
either, and the proxy logged nothing a jail could read. Nobody could see the jails without a shell:
|
||||||
|
the module's three tools existed in code and were served by nothing, and the seat it holds declared
|
||||||
|
no verbs.
|
||||||
|
|
||||||
|
Three things were missing and they are three shapes the mesh already has. The packet filter's seat
|
||||||
|
serves verbs every holder owes ([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)); the
|
||||||
|
intrusion seat serves none. A module's `listens` compose into the machine's filter, and [to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
|
||||||
|
says a module's `jails` compose into the machine's intrusion prevention the same way — the controller
|
||||||
|
composes them, and no module declares one. And a jail reads a log; a container's output goes to a
|
||||||
|
file of the runtime's own under a path that changes when the container is recreated, which is why
|
||||||
|
no jail could read the mail front end, the forge or the proxy, however they logged.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**1. The `node-intrusion-prevention` seat serves four verbs**, and a module that claims it serves
|
||||||
|
all four or is refused the claim, as with every seat:
|
||||||
|
|
||||||
|
- `status` — every jail with what it watches, how many addresses it is counting failures against and
|
||||||
|
holding now, and the totals since it started; one jail's detail when named. Read-only.
|
||||||
|
- `banned` — every address banned now, with the jail holding it, when it was banned and when the ban
|
||||||
|
ends. Read-only.
|
||||||
|
- `ban` — ban one address in one jail now, for that jail's ban time. An operator's act on the live
|
||||||
|
ban list, which the mesh composes the rules for and never writes itself.
|
||||||
|
- `unban` — let one address go, from one jail or from every jail.
|
||||||
|
|
||||||
|
A holder may serve its own tools beside these; the fail2ban module reads one jail's effective
|
||||||
|
settings as its own.
|
||||||
|
|
||||||
|
**2. A container may log to the journal.** `logging: journald` on a container has the host run it
|
||||||
|
with the journal as its log driver; the journal keeps the container's name on every line, and
|
||||||
|
`docker logs` keeps working. Where a container logs is part of its spec, so moving it recreates the
|
||||||
|
container, and the only place besides the runtime's own file is the journal: a machine's intrusion
|
||||||
|
prevention reads the journal already, for the ssh daemon, and a container that logs there is read
|
||||||
|
the same way, by the container's name, whatever the container is called by the runtime this time.
|
||||||
|
|
||||||
|
**3. A module with a door declares its jail, and the holder composes them.** What to-be 31 designed
|
||||||
|
is now the rule: a module whose service authenticates from outside — the mail front end, the forge,
|
||||||
|
the public proxy — declares in its manifest what a failed attempt looks like in its log and how to
|
||||||
|
ban on it, naming no node and no path; the module that holds the intrusion seat declares where the
|
||||||
|
composed jails and filters land, and the mesh writes them on every machine that runs both. A machine
|
||||||
|
not running the module has no such jail. The holder restarts its daemon on the composed file.
|
||||||
|
|
||||||
|
**4. The base is strict, and the mesh's own range is never banned.** Three failures in a day ban for
|
||||||
|
a day, on every jail unless the jail says otherwise; banned twice in two weeks, by any jail, is
|
||||||
|
banned for four. The attackers this mesh sees pace themselves under any ten-minute window; a day's
|
||||||
|
window counts them. A person who mistypes three times from one address is out for a day from that
|
||||||
|
address, and never from a machine of the mesh, whose range stays in the never-banned list the module
|
||||||
|
has carried since [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md). The operator
|
||||||
|
chose this knowing it.
|
||||||
|
|
||||||
|
**5. The proxy says a refused name in its log.** A request for a name this mesh does not serve, from
|
||||||
|
outside, is what a scanner does; the proxy already logged a certificate refused for such a name, and
|
||||||
|
now logs the plain request too, with the asking address last, as its own jail's filter expects it.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The seat's row gains four verbs; a mesh that already runs widens its row at the next controller
|
||||||
|
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.
|
||||||
|
- The fail2ban module declares where jails compose (`jailing`) and the directory the filters go in;
|
||||||
|
the mail, forge and proxy modules each declare one jail reading the journal by their container's
|
||||||
|
name. The composed jail file is the one resource the daemon restarts on when a module arrives or
|
||||||
|
leaves a machine.
|
||||||
|
- The two base jails and the composed ones take the day's window; the ssh jail's ten minutes are
|
||||||
|
gone. An address banned on the first day of this record stays banned for the day.
|
||||||
|
- The module's three old tools, served by nothing, are replaced by the seat's four verbs and one
|
||||||
|
own tool; `fail2ban_status` as a name is gone.
|
||||||
|
|
||||||
|
## How this is checked
|
||||||
|
|
||||||
|
| Rule | Checked by |
|
||||||
|
|---|---|
|
||||||
|
| The seat declares the four verbs; a claim that serves fewer is refused by name | the catalogue's seat tests |
|
||||||
|
| `status`, `banned`, `ban` and `unban` read and steer the daemon through its client, with the shapes fail2ban 1.1.0 printed live; a non-address and a non-name are refused before anything runs | the module's tests over a fake command runner |
|
||||||
|
| A container's `logging` reaches the runtime's arguments and its spec; a place other than the journal is refused | host tests |
|
||||||
|
| A module's jails compose into the holder's file and a filter per jail, and the file is written empty when none is declared | the controller's composition tests (to-be 31) |
|
||||||
|
| The proxy logs a refused name with the address last | the proxy's tests |
|
||||||
|
| A jail's pattern names `<HOST>` once per shape, since two is a duplicate capture group and costs the machine every ban | the catalogue's manifest tests |
|
||||||
|
| Live | done 2026-10-02: `status` and `banned` answered on both servers through the console; the proxy's jail counted seven refusals on the home server; a documentation address banned in the ssh jail came back with its end time and was released |
|
||||||
|
|
||||||
|
## Built and proven live, 2026-10-02
|
||||||
|
|
||||||
|
All five rules are in the mesh. The host carries `logging`; the controller's seat row carries the four
|
||||||
|
verbs and the proxy says a refused name in its log; the fail2ban module holds the seat from a runtime
|
||||||
|
with the daemon's socket shared in, composes the jails, and the mail front end, the forge and the
|
||||||
|
proxy each declare one. Through the console on the control node: `status` listed five jails with what
|
||||||
|
each watches, `banned` listed the nine the long jail holds, and a documentation address banned in the
|
||||||
|
ssh jail came back with its ban's end time and was released again. On the home server the proxy's jail
|
||||||
|
had counted seven refusals within minutes of starting.
|
||||||
|
|
||||||
|
**One fault, found by the machine and not by a test.** The proxy's pattern matched two shapes of
|
||||||
|
refusal in one expression and so named `<HOST>` twice. fail2ban expands that placeholder into a named
|
||||||
|
capture group; two of them is a duplicate group name, and the daemon refuses *its whole configuration*
|
||||||
|
and exits — both servers kept no bans at all for about ten minutes, every jail and not the one at
|
||||||
|
fault. The pattern is now one per shape. A manifest check refuses the mistake at merge time, naming
|
||||||
|
what it would cost, which is the only reason this record can claim the rule rather than the instance.
|
||||||
|
|
||||||
|
## 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 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)
|
||||||
+118
@@ -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)
|
||||||
+117
@@ -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
|
||||||
+163
@@ -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
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
topic: the mesh
|
||||||
|
status: accepted
|
||||||
|
date: 2026-10-02
|
||||||
|
deciders: jochen
|
||||||
|
reconstructed: false
|
||||||
|
extends: 02-DECISIONS/0005-the-node-host.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 184. A service the mesh asked to run is still running a moment later
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The host already refuses to take a service manager's word for it. Three places in one function read
|
||||||
|
a unit back after acting on it, each with a comment saying why: *a service manager accepting a
|
||||||
|
command says the transaction was accepted, not that the unit is running — one that starts and
|
||||||
|
immediately dies satisfies it.* The intent was right and the implementation did not reach it.
|
||||||
|
|
||||||
|
On 2026-10-02 the mesh composed a fail2ban jail whose pattern the daemon refused. The host wrote the
|
||||||
|
files, restarted the service, read the unit back and reported *restarted*. The unit was `active` at
|
||||||
|
that instant and `failed` 221 milliseconds later, which the unit's own record states. Both public
|
||||||
|
machines then kept no bans at all — every jail, not the one at fault — and nothing in the mesh said
|
||||||
|
so. The fault was found by calling a tool that needed the daemon, not by the mesh noticing.
|
||||||
|
|
||||||
|
The read-back races the failure. A service manager returns when it has started the process; a daemon
|
||||||
|
that reads its configuration, refuses it and exits does so a fraction of a second afterwards. One
|
||||||
|
look sees `activating` or `active` whatever the process is about to do, and *the host reports success
|
||||||
|
for a machine that is already wrong* — the one shape of failure this host exists to refuse
|
||||||
|
([ADR 0005](0005-the-node-host.md)).
|
||||||
|
|
||||||
|
A command the module declares — *test the configuration before restarting* — was considered and
|
||||||
|
rejected. The link carries no actions ([ADR 0005](0005-the-node-host.md)), and a verification
|
||||||
|
command is a command: a declaration that carried one would be remote execution over the bus,
|
||||||
|
arriving as root on every machine, which is a far larger door than the fault it closes. The host
|
||||||
|
does not need one. It already knows what it asked for.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**1. A unit the host has just asked to run is read twice**, with a pause between the reads long
|
||||||
|
enough for a daemon that refuses its configuration to have exited. Not running at the second look is
|
||||||
|
a failure of that resource, named with the unit and the state it is in — the same failure the single
|
||||||
|
read was always meant to catch.
|
||||||
|
|
||||||
|
**2. It is never a wait for a unit to come up.** A unit still starting reads as running at both
|
||||||
|
looks and is accepted, exactly as before. What the second look catches is a unit that *was* running
|
||||||
|
and is not any more. A service asked to be stopped is not waited on at all.
|
||||||
|
|
||||||
|
**3. The host tests nothing and runs nothing of a module's.** The second look is the host checking
|
||||||
|
the state it was told to establish, which is its whole job; the declaration gains no vocabulary, and
|
||||||
|
no command reaches a machine that did not already come from a built artifact.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every apply that starts, restarts or reloads a service spends a moment confirming it. The cost is
|
||||||
|
bounded by the number of services that changed in that apply, which is usually none.
|
||||||
|
- A module whose configuration the mesh composes — the packet filter, the intrusion prevention, the
|
||||||
|
resolver — now fails its apply when the composition is bad, instead of reporting success onto a
|
||||||
|
dead daemon. `status` names the machine, which is how the operator finds out.
|
||||||
|
- It does not prevent the bad composition. [ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)'s
|
||||||
|
manifest check is what refuses the one that caused this, at merge time; this record is what makes
|
||||||
|
the *next* one visible within a minute rather than invisible until something asks the daemon a
|
||||||
|
question.
|
||||||
|
|
||||||
|
## How this is checked
|
||||||
|
|
||||||
|
| Rule | Checked by |
|
||||||
|
|---|---|
|
||||||
|
| A unit that is running at the first look and dead at the second fails the apply, naming the unit and its state | a host test over a service manager that answers as systemd does |
|
||||||
|
| A unit still starting is accepted at both looks | a host test |
|
||||||
|
| A service asked to be stopped is not waited on | a host test |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR 0005](0005-the-node-host.md), [ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)
|
||||||
|
- [Design 05 — The node host](../03-DESIGN/01-to-be/05-the-node-host.md)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
topic: the mesh
|
||||||
|
status: accepted
|
||||||
|
date: 2026-10-02
|
||||||
|
deciders: jochen
|
||||||
|
reconstructed: false
|
||||||
|
extends: 02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 185. A control plane behind its seat's row serves what it can
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The mesh's own verbs are the controller seat's tools, and the seat's row is the store's
|
||||||
|
([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)). A control plane reads the
|
||||||
|
row at start and installs a handler per verb; a verb the row carries that the binary cannot run was
|
||||||
|
refused at start rather than at the first call, so that a disagreement between the row and the
|
||||||
|
binary was said early. The refusal aborted the start.
|
||||||
|
|
||||||
|
On 2026-10-02 a merge added one verb. The new control plane started, widened the row, and ran. A
|
||||||
|
push a few seconds later recreated its container at the previous image — a stale declaration from
|
||||||
|
an overlapping wave, [issue 201](../04-ISSUES/201-a-push-recreated-the-controller-behind-the-row-its-successor-wrote/00-report.md) —
|
||||||
|
and the older binary read a row naming a word it had never heard. It refused to start, and kept
|
||||||
|
refusing. The mesh had no voice for ten minutes: no verb answered, no node could be pushed, no build
|
||||||
|
was dispatched, and `status` said nothing because `status` is one of the verbs that had stopped
|
||||||
|
being served. The way back was a person running the binary by hand outside its service, because the
|
||||||
|
push that would have replaced it is itself a verb of the control plane that was down.
|
||||||
|
|
||||||
|
The check was right about the fact and wrong about the cost. A row ahead of a binary is the ordinary
|
||||||
|
state of a roll-out: the row is widened by whichever control plane starts first, and a mesh with one
|
||||||
|
control plane sees that gap on every merge that adds a verb. Making it fatal turned a transient into
|
||||||
|
an outage with no path out that did not need a human.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**1. A control plane serves the verbs it can run and does not refuse to start for the ones it
|
||||||
|
cannot.** The row remains the authority on what the seat serves; this is only about what this binary
|
||||||
|
does when it is behind the row.
|
||||||
|
|
||||||
|
**2. A verb it cannot run answers the reason.** Not silence and not a missing subject: a caller gets
|
||||||
|
a sentence naming the verb, saying this control plane cannot run it and that it is a verb of a newer
|
||||||
|
build. A verb that is simply absent from the row is still not served at all — that is the row
|
||||||
|
deciding, which is unchanged.
|
||||||
|
|
||||||
|
**3. It says so once at start**, naming every verb of the row it cannot run, so the gap is visible
|
||||||
|
in the log of the thing that has it rather than only at the moment somebody calls one.
|
||||||
|
|
||||||
|
**4. A mesh with no controller seat at all is still a refusal.** That is not a version gap, it is a
|
||||||
|
mesh that has not been seeded, and nothing this control plane does would be meaningful.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- An overlapping roll-out costs the verbs the newer build added, for as long as the older binary is
|
||||||
|
in place. Everything else — every push, every build, every read — keeps working, and the ordinary
|
||||||
|
machinery that notices a machine is behind is what puts the newer binary back.
|
||||||
|
- The log gains one line on a control plane that is behind, and nothing on one that is not.
|
||||||
|
- Issue 201's other half remains: the push that sent a stale declaration is a race worth closing on
|
||||||
|
its own terms. This record makes that race survivable rather than fatal, which is the difference
|
||||||
|
between a transient and an outage, and is deliberately the cheaper half.
|
||||||
|
|
||||||
|
## How this is checked
|
||||||
|
|
||||||
|
| Rule | Checked by |
|
||||||
|
|---|---|
|
||||||
|
| A row carrying a verb this build cannot run still serves every verb it can, and names the one it cannot | a controller test over a widened row |
|
||||||
|
| The unknown verb answers a sentence naming itself and saying this build is behind | the same test |
|
||||||
|
| A mesh with no controller seat is refused | the existing start-up path |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
|
||||||
|
- [Issue 201](../04-ISSUES/201-a-push-recreated-the-controller-behind-the-row-its-successor-wrote/00-report.md)
|
||||||
|
- [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
topic: the mesh
|
||||||
|
status: accepted
|
||||||
|
date: 2026-10-02
|
||||||
|
deciders: jochen
|
||||||
|
reconstructed: false
|
||||||
|
extends: 02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 186. A ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
[ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md) gave the
|
||||||
|
public proxy a jail. Within the hour the home server's ban list held `192.168.1.1` — the house's own
|
||||||
|
router. The router reflects local traffic, so every client in the building reaches that machine as
|
||||||
|
the gateway's address; one local request for a name the mesh does not serve, three times in a day,
|
||||||
|
and the whole house is refused by the machine it was asking. The jails inherited an `ignoreip` of
|
||||||
|
the loopback and the mesh's own range, which was right when the only jail read the ssh daemon and
|
||||||
|
the only clients were the mesh's; a jail on a public front door sees the neighbours too.
|
||||||
|
|
||||||
|
The same jail broke the other half of [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md).
|
||||||
|
The home server began reading *NOT the mesh alone: 1 rule set the mesh did not write refuses traffic
|
||||||
|
here*, and the rule set named was the mesh's own ban chain, written by the mesh's own intrusion
|
||||||
|
prevention minutes earlier. The host's reader of the legacy filter required every path into a chain
|
||||||
|
of refusals to come from a built-in chain whose policy accepts, before it would call that chain a
|
||||||
|
ban. On that machine the chain hangs off the container runtime's user chain as well as the input
|
||||||
|
chain, and the runtime had set the forward policy to DROP — so the mesh reported its own work as a
|
||||||
|
foreigner's, on the one machine where the group's exit condition was supposed to hold.
|
||||||
|
|
||||||
|
Both faults are one mistake in two places: a rule written about the public internet, applied to
|
||||||
|
everything that arrives.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**1. A ban list never holds a neighbour.** The jails the mesh composes never ban a source on a
|
||||||
|
private range — the mesh's own range, which was already named rather than written
|
||||||
|
([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)), and every address space
|
||||||
|
reserved for private use beside it, in both families. A machine behind a router that reflects local
|
||||||
|
traffic sees its whole building as one address; a ban there is a self-inflicted outage, and the
|
||||||
|
sources worth banning are not on those ranges in the first place.
|
||||||
|
|
||||||
|
**2. The mesh's own bans are its own wherever they hang.** A chain of refusals is a ban list when
|
||||||
|
every refusal names the sources it refuses and the chain accepts nothing — the rule the host already
|
||||||
|
applied to the packet filter's own tables, now applied to the legacy filter too, and nothing more.
|
||||||
|
The policy of the chains that jump into it says nothing about what it is: that policy is already
|
||||||
|
classified where it belongs, as the container runtime's, and requiring it here counted it twice.
|
||||||
|
|
||||||
|
**3. A chain that accepts anything is still not a ban.** That is what keeps a predecessor's
|
||||||
|
allow-these-and-drop-the-rest chain classified as something an operator must look at, which is the
|
||||||
|
distinction [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md) exists to draw.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The composed jails gain the private ranges in their never-ban list. An address already banned
|
||||||
|
stays banned until it is released; the house's router was released by hand the moment it was found.
|
||||||
|
- The home server reads *the mesh alone* again, which is group 7's exit condition and was false for
|
||||||
|
about an hour.
|
||||||
|
- A machine whose apply fails for an unrelated reason does not revisit its found firewall's record
|
||||||
|
at all — the step runs only after a clean apply ([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)).
|
||||||
|
The home server's record therefore still reads *retired by the mesh* although the front end is
|
||||||
|
uninstalled, and will correct itself once that machine's own stuck module is fixed. It is a stale
|
||||||
|
record, not a wrong machine.
|
||||||
|
- The record number the front end's removal was given moved under it: another session took 0175
|
||||||
|
while that record was in review, and it is now
|
||||||
|
[ADR 0180](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md). The citations
|
||||||
|
the host and the control plane print were pointing at an unrelated record and are corrected here.
|
||||||
|
|
||||||
|
## How this is checked
|
||||||
|
|
||||||
|
| Rule | Checked by |
|
||||||
|
|---|---|
|
||||||
|
| A private source is never banned | the module's jail configuration, read back by `fail2ban.fail2ban_settings` on a machine |
|
||||||
|
| The mesh's own ban chain reads as a ban behind a dropping forward policy | a host test over the home server's own captured rule set |
|
||||||
|
| A chain that accepts anything is not a ban | a host test |
|
||||||
|
| Live | the home server reads *the mesh alone*; no ban held on either machine is a private address |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.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 0180](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md)
|
||||||
|
- [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md), [Design 31 — A module declares its fail2ban jail](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
|
||||||
@@ -182,7 +182,11 @@ 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)
|
- **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)
|
- **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)
|
- **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)
|
||||||
|
- **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)
|
- **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)
|
||||||
|
- **0184** — [A service the mesh asked to run is still running a moment later](0184-a-service-the-mesh-asked-to-run-is-still-running-a-moment-later.md)
|
||||||
|
- **0185** — [A control plane behind its seat's row serves what it can](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md)
|
||||||
|
- **0186** — [A ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang](0186-a-ban-list-never-holds-a-neighbour.md)
|
||||||
|
|
||||||
### Its tiers, from the bottom up
|
### Its tiers, from the bottom up
|
||||||
|
|
||||||
@@ -277,6 +281,9 @@ python3 00-META/checks/index.py fail if stale
|
|||||||
- **0175** — [One tool runtime per node serves every module's tools, on the host side](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
|
- **0175** — [One tool runtime per node serves every module's tools, on the host side](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
|
||||||
- **0176** — [The login shell is a node seat held by one shell module, and `execute` is its contract](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
|
- **0176** — [The login shell is a node seat held by one shell module, and `execute` is its contract](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
|
||||||
- **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)
|
- **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
|
### How it is built
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ status: in-progress
|
|||||||
code: [mesh-host]
|
code: [mesh-host]
|
||||||
updated: 2026-10-02
|
updated: 2026-10-02
|
||||||
decisions:
|
decisions:
|
||||||
|
- 02-DECISIONS/0184-a-service-the-mesh-asked-to-run-is-still-running-a-moment-later.md
|
||||||
- 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
|
- 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
|
||||||
- 02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md
|
- 02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md
|
||||||
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
|
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
|
||||||
@@ -498,3 +499,17 @@ run and reported; the exit follows an in-flight apply rather than interrupting i
|
|||||||
not start is rolled back once and the second failure halts; a completed reconcile retires what is older
|
not start is rolled back once and the second failure halts; a completed reconcile retires what is older
|
||||||
than the predecessor and never the predecessor; and the newest of two delivered versions is the one
|
than the predecessor and never the predecessor; and the newest of two delivered versions is the one
|
||||||
that runs.
|
that runs.
|
||||||
|
|
||||||
|
## A service is still running a moment later, 2026-10-02
|
||||||
|
|
||||||
|
[ADR 0184](../../02-DECISIONS/0184-a-service-the-mesh-asked-to-run-is-still-running-a-moment-later.md).
|
||||||
|
The host has always read a unit back after acting on it, because a service manager accepting a
|
||||||
|
command says the transaction was accepted and nothing about the process. The read raced the failure:
|
||||||
|
a daemon that refuses the configuration the mesh just wrote exits a fraction of a second after the
|
||||||
|
manager returns, and one look sees it alive. So the host looks twice, with a pause between, and a
|
||||||
|
unit that was running and is not any more fails its resource by name. A unit still coming up reads
|
||||||
|
as running at both looks and is accepted; a service asked to stop is not waited on.
|
||||||
|
|
||||||
|
No command for this reaches a machine. A module declaring *how to test my configuration* was weighed
|
||||||
|
and refused: the link carries no actions, and a verification command is one. The host is checking
|
||||||
|
the state it was told to establish, which is what it is for. *How it is checked:* ADR 0184's table.
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ status: in-progress
|
|||||||
code:
|
code:
|
||||||
- mesh-controller internal/licences
|
- mesh-controller internal/licences
|
||||||
- mesh-controller cmd/mesh-controller/licence.go
|
- mesh-controller cmd/mesh-controller/licence.go
|
||||||
updated: 2026-09-05
|
updated: 2026-10-02
|
||||||
decisions:
|
decisions:
|
||||||
- 02-DECISIONS/0024-model-access-is-a-provision.md
|
- 02-DECISIONS/0024-model-access-is-a-provision.md
|
||||||
|
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
- 02-DECISIONS/0009-modules-and-the-graph.md
|
||||||
- 02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md
|
- 02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md
|
||||||
- 02-DECISIONS/0055-model-access-is-answered-by-a-licence-or-a-node.md
|
- 02-DECISIONS/0055-model-access-is-answered-by-a-licence-or-a-node.md
|
||||||
@@ -96,6 +97,14 @@ So `(node, module)` tells them apart, and asking for a licence per session neede
|
|||||||
identity. Checked rather than argued: two sessions on one machine hold different licences, each is
|
identity. Checked rather than argued: two sessions on one machine hold different licences, each is
|
||||||
given its own key, and releasing one leaves the other.
|
given its own key, and releasing one leaves the other.
|
||||||
|
|
||||||
|
*2026-10-02:* the operator's own agent at a terminal is **not** a consumer of this provision: it is
|
||||||
|
coupled to an Anthropic grant and nothing else, so it uses the `anthropic-licence-manager` seat, whose
|
||||||
|
holder owns the Anthropic licences, their bindings and their rotation, and hands each node's agent its
|
||||||
|
token over the bus
|
||||||
|
([ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md),
|
||||||
|
[36](36-the-operators-agent-on-a-machine.md), [39](39-the-anthropic-licence-manager.md)). This
|
||||||
|
provision stays for the consumers that do not care which vendor answers.
|
||||||
|
|
||||||
**What is still open is the rest of the gap, and it is the harder half.** A *worker* is not one
|
**What is still open is the rest of the gap, and it is the harder half.** A *worker* is not one
|
||||||
per machine — many can run on one, from one module — so `(node, module)` cannot name them apart
|
per machine — many can run on one, from one module — so `(node, module)` cannot name them apart
|
||||||
and this reasoning does not extend to them. That belongs with
|
and this reasoning does not extend to them. That belongs with
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ code:
|
|||||||
- mesh-controller cmd/mesh-controller
|
- mesh-controller cmd/mesh-controller
|
||||||
updated: 2026-10-02
|
updated: 2026-10-02
|
||||||
decisions:
|
decisions:
|
||||||
|
- 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/0112-a-module-definition-names-no-node-mesh-or-path.md
|
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||||
- 02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md
|
- 02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md
|
||||||
- 02-DECISIONS/0051-shared-data-is-the-operators.md
|
- 02-DECISIONS/0051-shared-data-is-the-operators.md
|
||||||
@@ -171,6 +173,15 @@ fact, the home as a placement root, and what the mesh may and may not do under a
|
|||||||
decision this document names but no record states. They are the next records to write, before the
|
decision this document names but no record states. They are the next records to write, before the
|
||||||
family of §2 modules is built.
|
family of §2 modules is built.
|
||||||
|
|
||||||
|
*2026-10-02:* two of them are written. [ADR 0181](../../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)
|
||||||
|
records the account as a node fact and the home as a placement root, reconstructed from what shipped;
|
||||||
|
[ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
|
||||||
|
generalises §3's boundary to every directory under a home. The first member of the §2 family is
|
||||||
|
designed in [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md). User-scoped
|
||||||
|
units are [ADR 0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md),
|
||||||
|
written the same day; still unwritten: several accounts per node, and the CA. On the same day every node of the
|
||||||
|
live mesh still carried an empty account.
|
||||||
|
|
||||||
## Why now, and why not yet
|
## Why now, and why not yet
|
||||||
|
|
||||||
**Why it matters:** when HAL retires, the generators that keep `~/.ssh`, shell config and the
|
**Why it matters:** when HAL retires, the generators that keep `~/.ssh`, shell config and the
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
---
|
---
|
||||||
layer: to-be
|
layer: to-be
|
||||||
status: proposed
|
status: in-progress
|
||||||
code: []
|
code:
|
||||||
updated: 2026-09-27
|
- mesh-controller: internal/catalogue/jails_into.go, internal/catalogue/manifest.go (Jail, Jailing)
|
||||||
|
- mesh-catalog: modules/fail2ban (jailing, the base and the seat's verbs), modules/mailu, modules/route-proxy, modules/gitea (jails)
|
||||||
|
- mesh-host: internal/declaration/declaration.go (a container's logging)
|
||||||
|
updated: 2026-10-02
|
||||||
decisions:
|
decisions:
|
||||||
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||||
|
- 02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md
|
||||||
|
- 02-DECISIONS/0186-a-ban-list-never-holds-a-neighbour.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# 31 — A module declares its fail2ban jail, and the mesh composes them per node
|
# 31 — A module declares its fail2ban jail, and the mesh composes them per node
|
||||||
@@ -63,3 +68,38 @@ jail, composed from the postgres module's manifest, without anyone editing a nod
|
|||||||
beside)
|
beside)
|
||||||
- mesh-catalog `modules/fail2ban` (the base: sshd, recidive, ignoreip); the service modules
|
- mesh-catalog `modules/fail2ban` (the base: sshd, recidive, ignoreip); the service modules
|
||||||
(`postgres`, `mssql`, `mailu`) that will declare jails
|
(`postgres`, `mssql`, `mailu`) that will declare jails
|
||||||
|
|
||||||
|
## Decided and built, 2026-10-02
|
||||||
|
|
||||||
|
[ADR 0179](../../02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)
|
||||||
|
made this the rule and built it. A module declares `jails` — each a name, the `failregex` of a
|
||||||
|
failed attempt in its log, and the stanza's own keys — and the fail2ban module declares `jailing`:
|
||||||
|
the one file the stanzas compose into and the directory each filter lands in. The controller gathers
|
||||||
|
every assigned module's jails per node into those; the holder's daemon restarts on the composed file.
|
||||||
|
|
||||||
|
What made it workable was the log. A container's output went to a file of the runtime's own, under
|
||||||
|
a path that changes when the container is recreated, so no jail could read a container's service
|
||||||
|
however it logged. A container now declares `logging: journald`, the host runs it with the journal as
|
||||||
|
its driver, and a jail reads it with `backend = systemd` and a `journalmatch` on the container's
|
||||||
|
name — the same way the base's ssh jail has always read the ssh daemon. The first three doors: the
|
||||||
|
mail front end (every login failure on its proxying ports), the forge (a failed authentication
|
||||||
|
attempt) and the public proxy (a certificate or request for a name the mesh does not serve, which
|
||||||
|
the proxy now says in its log). The base is strict — three in a day for a day; twice banned in two
|
||||||
|
weeks for four — and the mesh's own range stays never banned.
|
||||||
|
|
||||||
|
The seat the module holds serves `status`, `banned`, `ban` and `unban`, from a runtime that carries
|
||||||
|
only the fail2ban client with the daemon's socket shared in; the jails are composed, the ban list is
|
||||||
|
the daemon's, and both are read through the console.
|
||||||
|
|
||||||
|
*How it is checked:* ADR 0179's table.
|
||||||
|
|
||||||
|
## What the first jails taught, 2026-10-02
|
||||||
|
|
||||||
|
[ADR 0186](../../02-DECISIONS/0186-a-ban-list-never-holds-a-neighbour.md). Within an hour of the
|
||||||
|
first public jail the home server had banned the house's own router: the router reflects local
|
||||||
|
traffic, so every client in the building arrives as the gateway's address. The never-ban list now
|
||||||
|
holds every private range as well as the mesh's own. And the mesh read its own ban chain as a
|
||||||
|
foreign rule set on that machine, because the chain hangs off the container runtime's user chain and
|
||||||
|
that machine's forward policy is the runtime's DROP — the reader now calls a chain of source-named
|
||||||
|
refusals a ban wherever it hangs, as it already did for the packet filter's own tables.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ code: [mesh-controller, mesh-tools]
|
|||||||
updated: 2026-10-02
|
updated: 2026-10-02
|
||||||
decisions:
|
decisions:
|
||||||
- 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md
|
- 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md
|
||||||
|
- 02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md
|
||||||
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
|
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
|
||||||
- 02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md
|
- 02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md
|
||||||
- 02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md
|
- 02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md
|
||||||
@@ -184,6 +185,18 @@ container to declare a capability. Removing a predecessor's rule set is an opera
|
|||||||
through the seat, recorded on the bus, instead of a shell on the machine. *How it is checked:* ADR
|
through the seat, recorded on the bus, instead of a shell on the machine. *How it is checked:* ADR
|
||||||
0169's table.
|
0169's table.
|
||||||
|
|
||||||
|
## The intrusion seat's verbs, 2026-10-02
|
||||||
|
|
||||||
|
[ADR 0179](../../02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md).
|
||||||
|
The second node-scoped seat to carry verbs: `node-intrusion-prevention` serves `status` (every jail
|
||||||
|
with what it watches and holds), `banned` (every address held now, with its jail and when the ban
|
||||||
|
ends), `ban` and `unban` (an operator's act on the live ban list). The fail2ban module serves them
|
||||||
|
from a runtime that carries only the daemon's client, the socket shared in from the machine — no
|
||||||
|
capability, no machine network, since the daemon on the machine does the banning. That runtime is the
|
||||||
|
per-module container [ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
|
||||||
|
retires; the verbs and the client are the same code once the node's own runtime loads them as a bundle.
|
||||||
|
The module's own tool beside them reads one jail's effective settings. *How it is checked:* ADR 0179's table.
|
||||||
|
|
||||||
## What this does not settle
|
## What this does not settle
|
||||||
|
|
||||||
- Which verbs each seat should serve. That is a decision per seat, and the reason to do it slowly: a
|
- Which verbs each seat should serve. That is a decision per seat, and the reason to do it slowly: a
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ module's credential and listens on loopback. It has no state, no provision, no s
|
|||||||
the bus, which it gets the way every module does: a credential the mesh minted for `<node>.mesh-console`,
|
the bus, which it gets the way every module does: a credential the mesh minted for `<node>.mesh-console`,
|
||||||
sealed to the machine, delivered as the module's own secret.
|
sealed to the machine, delivered as the module's own secret.
|
||||||
|
|
||||||
|
*2026-10-02:* it gains one provision, at node scope — the MCP endpoint on loopback, serving the port
|
||||||
|
the machine gave it — so that a module whose software must be told where the console is requires that
|
||||||
|
and is coupled to an endpoint rather than to a module's name
|
||||||
|
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)). The first
|
||||||
|
consumer is the operator's agent, [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md) §6;
|
||||||
|
a machine without the console refuses such a module by name. Nothing else above changes: no seat, no
|
||||||
|
state, no tools of its own.
|
||||||
|
|
||||||
Its manifest says three things nothing else in the catalogue says together:
|
Its manifest says three things nothing else in the catalogue says together:
|
||||||
|
|
||||||
- `invokes: ["*"]` — it calls every tool on the mesh, and the bus grants exactly that publish side;
|
- `invokes: ["*"]` — it calls every tool on the mesh, and the bus grants exactly that publish side;
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
layer: to-be
|
||||||
|
status: designed
|
||||||
|
code: []
|
||||||
|
updated: 2026-10-02
|
||||||
|
decisions:
|
||||||
|
- 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/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||||
|
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||||
|
- 02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md
|
||||||
|
- 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
|
||||||
|
- 02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md
|
||||||
|
- 02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md
|
||||||
|
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 36 — The operator's agent on a machine: the `claude-code` module
|
||||||
|
|
||||||
|
**The agent a person runs at a terminal, put on the machine by the mesh, instructed by the mesh, pointed
|
||||||
|
at the console, and holding the licence the manager hands it.** It is a member of the family
|
||||||
|
[to-be 29 §2](29-a-node-has-operator-accounts.md) names, the modules that touch a person's machine, and
|
||||||
|
its counterpart is [39 — The Anthropic licence manager](39-the-anthropic-licence-manager.md).
|
||||||
|
|
||||||
|
What it replaces: the predecessor's module of the same name and a sibling, which placed six files under
|
||||||
|
the operator's home. The predecessor is retired; the six files are still on both workstations telling
|
||||||
|
every session to use tools that no longer exist.
|
||||||
|
|
||||||
|
**Three rules shape everything below.** The host is module-agnostic: it installs the package and gives
|
||||||
|
the module a state directory, and knows no vendor, no agent, no path under a home. The controller has no
|
||||||
|
part beyond resolving what it resolves for every module. And the module handles its own files: the
|
||||||
|
mesh's part of the agent's configuration is written by the module's own code, from what the mesh
|
||||||
|
delivered it and what the manager handed it.
|
||||||
|
|
||||||
|
## 1. Where the mesh's configuration lives: the agent's managed directory, not the home
|
||||||
|
|
||||||
|
The agent reads a machine-wide, administrator-owned configuration directory under `/etc`, documented
|
||||||
|
by the vendor: a managed settings file that outranks every user and project setting; a key in it that
|
||||||
|
adds HTTP tool servers *beside* a person's own without blocking them; and a managed instruction file every
|
||||||
|
session reads before the user's and the project's. The agent has **no** machine-wide directory for
|
||||||
|
rules, skills, slash commands or hooks; those exist only under a home or a project.
|
||||||
|
|
||||||
|
So the mesh's part of the agent's configuration lives there, **owned whole by the module**, and the home
|
||||||
|
is left alone. What the predecessor shipped as two rule files and two skills folds into the managed
|
||||||
|
instruction file and the manager's tools:
|
||||||
|
|
||||||
|
| the predecessor placed | becomes |
|
||||||
|
|---|---|
|
||||||
|
| `~/.claude/CLAUDE.md` | the managed instruction file: how a session on this mesh works (§3) |
|
||||||
|
| `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md` | sections of the same file: this node's identity, the repositories' conventions |
|
||||||
|
| `~/.claude/settings.json`, merged | the managed settings file: the mesh's keys only, outranking nothing a person did not also set |
|
||||||
|
| `~/.claude/skills/hal-switch-license/SKILL.md` | the manager seat's `switch` verb, listed by the console, and a sentence in the instruction file saying to use it |
|
||||||
|
| `~/.claude/skills/cleanup/SKILL.md` | nothing; it named the predecessor's forge |
|
||||||
|
| the console's entry in the agent's user-scope state | the managed settings' tool-server key, from the console's provision (§4) |
|
||||||
|
|
||||||
|
**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
|
||||||
|
module's own code writes for a subscription licence (§5). 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
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
in that directory carrying the node's name, the operator account, the console's endpoint, the module's
|
||||||
|
settings; the bus, the console's provision, and that it uses the `anthropic-licence-manager` seat.
|
||||||
|
Nothing under the home, nothing under `/etc`.
|
||||||
|
|
||||||
|
**Written by the module's code**, from the facts file and the manager's hand-over, whenever either
|
||||||
|
changes:
|
||||||
|
|
||||||
|
| path | content |
|
||||||
|
|---|---|
|
||||||
|
| the managed settings file | the mesh's keys: the tool servers (the console, plus any the operator declared as settings), the attribution trailers, and — for an API-key binding only — the key-helper that serves the key |
|
||||||
|
| the managed instruction file | §3 |
|
||||||
|
| the agent's credentials file under the operator's home | for a subscription binding only: the access token the manager handed over, as the operator, readable by the operator alone, atomic, no refresh token |
|
||||||
|
| the module's keypair in its state | made once, the private half never leaves (§5) |
|
||||||
|
|
||||||
|
Writing under `/etc` and as the operator under the home are two escalations the module's code performs
|
||||||
|
for itself; the mesh does not run the module as root for everyone, and the caller does not know
|
||||||
|
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
|
||||||
|
|
||||||
|
**Which settings keys are the mesh's.** A key is the mesh's when it encodes a rule of the mesh: the tool
|
||||||
|
servers that reach the mesh, the attribution convention of its repositories, the key-helper a binding
|
||||||
|
requires. The model, the spinner, the drafts and every other preference are the person's, and the
|
||||||
|
predecessor's experience with the model key is the evidence: a mesh that sets a preference reverts a
|
||||||
|
person's choice on every push.
|
||||||
|
|
||||||
|
## 3. What the instruction file says
|
||||||
|
|
||||||
|
Prose, not a paste; the file is the module's.
|
||||||
|
|
||||||
|
**How a session on this mesh works.** The console is the only path to the mesh, and its tools are the
|
||||||
|
vocabulary: the record is asked through the records module, symptom first — the literal error text before
|
||||||
|
a hypothesis ([ADR 0153](../../02-DECISIONS/0153-the-record-is-read-by-a-module-and-the-console-lists-it.md));
|
||||||
|
the mesh is asked and changed through the controller seat's verbs; the forge through the forge module's
|
||||||
|
tools; a licence through the `anthropic-licence-manager` seat's verbs, never by editing a file. The hard
|
||||||
|
rules in new words: a file the mesh manages is changed through the verb that owns it or through the
|
||||||
|
catalogue, never on disk; a store's database is never written by hand; main is never pushed; the mesh
|
||||||
|
creates no symlinks and nobody else does; a package is declared, not installed by hand. The glossary's
|
||||||
|
words, none of the predecessor's.
|
||||||
|
|
||||||
|
**Who this node is.** The node's name, from the facts file; the node's role, from the module's settings
|
||||||
|
on the node's layer; and that the other nodes are asked of the controller's `nodes` verb rather than
|
||||||
|
listed here, because a table is a copy that drifts.
|
||||||
|
|
||||||
|
**The repositories' conventions.** Concise commit messages in the imperative, about why; a branch, a
|
||||||
|
pull request and a human approval for every merge; test before pushing, because nodes update unattended;
|
||||||
|
the playbooks in the record.
|
||||||
|
|
||||||
|
## 4. The console
|
||||||
|
|
||||||
|
The module tells the agent where the console is, and the port is the console's to say. **The console
|
||||||
|
provides a node-scoped provision** — its MCP endpoint on loopback — serving the port the machine gave
|
||||||
|
it, and the module requires it. A requirement names what the consumer is coupled to
|
||||||
|
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)); co-location
|
||||||
|
resolves it; a machine without the console refuses the module by name. [To-be 34](34-the-console.md) is
|
||||||
|
amended in the same change; issue 192 (open) found the gap.
|
||||||
|
|
||||||
|
**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`,
|
||||||
|
validates a server and sets the setting through the controller's settings verb, so the list stays
|
||||||
|
declared state. 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.
|
||||||
|
|
||||||
|
**The entry's name is `mesh`.** The hand-made entry both workstations carry today is named after this
|
||||||
|
installation, which a definition may not be ([ADR 0155](../../02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md));
|
||||||
|
it is the person's to remove, and until then the agent sees the mesh's tools twice.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
decides it; 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;
|
||||||
|
- **serves `apply`**: the manager's hand-over, a token sealed to the module's key, with the licence's
|
||||||
|
name and kind. A rotation of the same licence is applied only if newer within one lineage; a switch is
|
||||||
|
applied regardless, because across licences the expiries are unrelated. The answer says applied or
|
||||||
|
refused and why, and never echoes a token;
|
||||||
|
- **pulls** at start and when its token nears expiry, by the seat's `current` verb, and keeps the last
|
||||||
|
token when the manager does not answer, saying so;
|
||||||
|
- **writes** for a subscription licence the credentials file as the operator, access-token-only; 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;
|
||||||
|
- **offers a login to the manager**: when the credentials file changes by a person's login, it reads the
|
||||||
|
account's identity from the agent's state file and offers the grant to the seat, sealed to the manager's
|
||||||
|
key, for adoption; the manager decides;
|
||||||
|
- **serves `licence_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.
|
||||||
|
|
||||||
|
Switching is the seat's `switch` verb, asked through the console; this module only applies what it is
|
||||||
|
handed.
|
||||||
|
|
||||||
|
## 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)).
|
||||||
|
None has one today; the operator states them first. **Per node:** the role. **Per mesh or per node:**
|
||||||
|
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
|
||||||
|
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
|
||||||
|
its licence; then the rest.
|
||||||
|
|
||||||
|
## 7. The package
|
||||||
|
|
||||||
|
The module declares the agent's package. The distribution every node runs does not carry it in its
|
||||||
|
repositories: the two workstations have it from a build the predecessor's helper made from the community
|
||||||
|
repository, and nothing updates it since. On those two the declaration is satisfied. **On a fresh machine
|
||||||
|
the host's package manager refuses it, in its own words, and the module is not applied there.** The
|
||||||
|
answer is a package repository for this ecosystem as a seat
|
||||||
|
([ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md)), fed by the builder
|
||||||
|
and trusted by every node's package manager; not built, and not this module's to build. The vendor's own
|
||||||
|
installer is rejected: it puts a self-updating binary under the person's home, invisible to the mesh.
|
||||||
|
|
||||||
|
## How it is checked
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| 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 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 |
|
||||||
|
|
||||||
|
## What this does not settle
|
||||||
|
|
||||||
|
- **Several operator accounts on one node** (ADR 0181 decides one).
|
||||||
|
- **A worker's own licence on a machine.** Every interactive session shares the node's one agent
|
||||||
|
directory and its licence, however many run. A worker runs from a home of its own with an agent
|
||||||
|
directory in it, bound to its own licence through the manager (to-be 39 §5); that is for when workers
|
||||||
|
exist, and nothing here changes for it.
|
||||||
|
- **The package repository seat** (§7).
|
||||||
|
- **How the module's tools are run** is decided: the node's tool runtime, host-side
|
||||||
|
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
|
||||||
|
The managed files and the credential write are tools of this module that runtime serves. Until the
|
||||||
|
runtime exists on every node, the module's code runs as a supervised process of its own
|
||||||
|
([ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)),
|
||||||
|
which changes nothing in what it writes.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR 0181](../../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md),
|
||||||
|
[ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md),
|
||||||
|
[ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the decisions
|
||||||
|
- [39 — The Anthropic licence manager](39-the-anthropic-licence-manager.md), [34 — The console](34-the-console.md), [29 — A node has operator accounts](29-a-node-has-operator-accounts.md)
|
||||||
|
- [research 018](../../01-RESEARCH/018-the-operators-machine-as-modules/00-overview.md) — the operator's machine as modules, and where tools run
|
||||||
|
- the vendor's documentation on managed settings, managed tool servers, the managed instruction file and the key-helper, read 2026-10-02
|
||||||
|
- the predecessor's two modules and the six files on the workstations, read 2026-10-02
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
layer: to-be
|
||||||
|
status: designed
|
||||||
|
code: []
|
||||||
|
updated: 2026-10-02
|
||||||
|
decisions:
|
||||||
|
- 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/0050-model-access-is-vendor-agnostic.md
|
||||||
|
- 02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md
|
||||||
|
- 02-DECISIONS/0113-the-vault-makes-every-secret.md
|
||||||
|
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
|
||||||
|
- 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
|
||||||
|
- 02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 39 — The Anthropic licence manager
|
||||||
|
|
||||||
|
**One module knows every Anthropic licence the mesh has, keeps each alive, decides which consumer gets
|
||||||
|
which, and hands every node's agent its token over the bus.**
|
||||||
|
[ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
|
||||||
|
decides it; this is the shape. It is the successor of the predecessor's manager module, built from what
|
||||||
|
that module learned the hard way, and the counterpart of [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md),
|
||||||
|
which is the consumer on every node.
|
||||||
|
|
||||||
|
## 1. What it is
|
||||||
|
|
||||||
|
A module, `claude-licence-manager`, holding the mesh-scoped seat **`anthropic-licence-manager`**. One
|
||||||
|
holder, on the node the operator assigns it to — the control node is the natural one, and nothing in the
|
||||||
|
definition says so. It requires a database for its own store and the bus; it claims the seat; it serves
|
||||||
|
the seat's verbs. It has no port, no route, no file under anyone's home.
|
||||||
|
|
||||||
|
Its store holds four things:
|
||||||
|
|
||||||
|
| table | holds |
|
||||||
|
|---|---|
|
||||||
|
| **licences** | name, kind (`subscription` or `api-key`), the account's identity (id, address, organisation) once adopted, the grant encrypted at rest, when the access token expires, when the refresh token expires, consecutive failures, the refresh lease, when a person was last notified |
|
||||||
|
| **bindings** | one row per consumer: kind (`node-agent`, `node-session`, `worker`), its key (the node, or the node and the worker), the licence, or *inherit* |
|
||||||
|
| **usage** | the vendor's readings per licence per period, raw beside normalised ([ADR 0054](../../02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md)) |
|
||||||
|
| **audit** | every switch, adoption, refusal and drift, with who asked |
|
||||||
|
|
||||||
|
**The grants are encrypted with a key the vault made for the manager** — its one `secret` requirement.
|
||||||
|
The vault keeps that key; the manager keeps the grants. That is ADR 0050's carve-out, one module, one
|
||||||
|
node, the long-lived grants only.
|
||||||
|
|
||||||
|
## 2. The licences it manages today
|
||||||
|
|
||||||
|
Two subscription accounts and one API key. They differ in kind and the manager treats them so:
|
||||||
|
|
||||||
|
| kind | what the grant is | refresh | what a node is handed | how the agent uses it |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `subscription` | an OAuth grant: an access token that lives hours and a refresh token that lives weeks | the manager rotates it, alone | the access token only | written into the agent's credentials file by the agent module, as the operator |
|
||||||
|
| `api-key` | a key the operator obtained from the vendor | none; a new key is a new adoption | the key | served to the agent through its key-helper setting; nothing is written under the home |
|
||||||
|
|
||||||
|
## 3. Keeping a grant alive
|
||||||
|
|
||||||
|
Carried from the predecessor, where each rule was earned by an incident:
|
||||||
|
|
||||||
|
- **One rotation source.** Only this module calls the vendor's token endpoint. An OAuth refresh is
|
||||||
|
presumed to rotate the refresh token, so a second refresher presenting the old one would kill the
|
||||||
|
grant; whether that presumption holds is to be measured in the lab, and the design is safe either way.
|
||||||
|
- **A lease per licence**, taken in the store before the row is read. A duplicate run sees the token its
|
||||||
|
predecessor just wrote, finds hours of life on it, and does nothing.
|
||||||
|
- **An expiry floor and a cadence.** Within an hour of expiry a refresh must happen; otherwise a grant is
|
||||||
|
rotated once it is older than a declared setting, so a node that misses one rotation still holds hours
|
||||||
|
of life and a broken refresh surfaces in minutes rather than the next morning.
|
||||||
|
- **Failure is counted and escalated once.** Consecutive failures are recorded; past a threshold a
|
||||||
|
notification is emitted, and at most once a day while it stays broken — the predecessor sent one alarm
|
||||||
|
411 times in 35 hours and the incident went unnoticed inside its own alarm.
|
||||||
|
- **A refresh token's own expiry is warned about three days ahead**, because the only remedy is a person
|
||||||
|
logging in again.
|
||||||
|
- **The vendor's reason is logged**, never only the status code: a malformed request and a revoked grant
|
||||||
|
both answer 400, and the predecessor built three concurrency fixes for a bug that was a wrong client id.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
runs. From then on:
|
||||||
|
|
||||||
|
- **On rotation**, the manager calls `claude-code.apply@<node>` on every node bound to the rotated
|
||||||
|
licence, with the new token sealed to that node's module key. The module answers *applied*, or
|
||||||
|
*refused* and why, and the manager records it.
|
||||||
|
- **On a switch**, the same call with the other licence's token, and the binding is the authority: the
|
||||||
|
module applies a bind without comparing expiries, because across two licences the numbers are
|
||||||
|
unrelated.
|
||||||
|
- **On a pull** — the module starting, or finding its token near expiry — the module calls the seat's
|
||||||
|
`current` verb for its binding and is answered sealed the same way.
|
||||||
|
- **Never as an event.** What the manager emits names the licence and the 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
|
||||||
|
rather than falling silent. A node whose module refuses — a wrong identity, a stale grant within one
|
||||||
|
lineage — is recorded as drift and reported.
|
||||||
|
|
||||||
|
## 5. Who gets which licence
|
||||||
|
|
||||||
|
Three consumer kinds, the predecessor's touchpoints with their fallbacks:
|
||||||
|
|
||||||
|
| consumer | bound by | falls back to | if the bound licence cannot be served |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **the node's interactive agent** | the node | nothing: an unbound node has no licence and the agent says so | keeps the last token, which expires within hours; a notification is emitted |
|
||||||
|
| **the mesh's session on a node** | the node, for that session | the node's agent licence | refused |
|
||||||
|
| **a worker** | the worker | the node's session licence, then the node's | refused: a worker never borrows a person's account |
|
||||||
|
|
||||||
|
**One agent directory per machine, shared by every interactive session**, so a node's binding is the
|
||||||
|
licence of all its sessions at once. A worker is a consumer of its own because it runs from a home of its
|
||||||
|
own, with its own agent directory and credentials file, which the agent module on that node writes for
|
||||||
|
it as it writes the operator's — the predecessor ran its agents exactly so.
|
||||||
|
|
||||||
|
**Binding is a person's act through the seat's verbs**, listed by the console: `bind`, `switch`,
|
||||||
|
`release`. **Exhaustion is observed, not acted on**: usage is read every few minutes, a crossing of a
|
||||||
|
declared threshold in the five-hour window is notified once per crossing, and moving a consumer is the
|
||||||
|
operator's call. Switching remains a reaction, not a declaration
|
||||||
|
([ADR 0024](../../02-DECISIONS/0024-model-access-is-a-provision.md)), and an automated policy — move to the
|
||||||
|
least-used licence, stay off a dying one — is designed later if wanted, on the readings this module
|
||||||
|
already keeps.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
argument:
|
||||||
|
|
||||||
|
- **From a node's login.** A person logs in on a node, as they always have. The agent module there reads
|
||||||
|
the account's identity from the agent's own state file, and offers the full grant to the seat sealed
|
||||||
|
to the manager's key. The manager adopts it into the licence the node is bound to **only if the
|
||||||
|
identity matches** that licence's recorded account; a licence not yet identified is identified by its
|
||||||
|
first adoption; a mismatch is refused and notified, because the predecessor once filed one account's
|
||||||
|
grant into another's row this way.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 7. What it emits and serves
|
||||||
|
|
||||||
|
**Events**, no secret in any: `licence.rotated`, `licence.switched`, `licence.adopted`,
|
||||||
|
`licence.failing`, `licence.refused`, `usage.read` — the audit logger records them all.
|
||||||
|
|
||||||
|
**The seat's verbs**, the contract every future holder must serve: `licences` (each with kind,
|
||||||
|
identity, expiry, failures, who is bound), `bindings`, `bind`, `switch`, `release`, `refresh` (now, one
|
||||||
|
or all), `usage` (current and history), `adopt`, `register` (a node's module key), `current` (a
|
||||||
|
consumer's token, sealed, asked by the consumer's module).
|
||||||
|
|
||||||
|
## 8. Settings
|
||||||
|
|
||||||
|
The refresh cadence; the usage threshold; the notification cooldown. Each declared with a default, so
|
||||||
|
one definition serves and one mesh may differ.
|
||||||
|
|
||||||
|
## How it is checked
|
||||||
|
|
||||||
|
| Check | Defends |
|
||||||
|
|---|---|
|
||||||
|
| two refresh runs started together rotate one grant once; the second does nothing and says so | ADR 0183, one rotation source |
|
||||||
|
| every event the manager emits is free of any token; the hand-over opens only with the receiving module's key | ADR 0183, to-be 32 §10 |
|
||||||
|
| a worker bound to a dead licence is refused, never answered with another licence's token | ADR 0183, the fallbacks |
|
||||||
|
| a grant offered with a mismatching identity is refused and one notification emitted | ADR 0183, attribution |
|
||||||
|
| a failing licence notifies once, and once a day after, not once per tick | §3 |
|
||||||
|
| the console lists the seat's verbs and `switch` changes a workstation's token end to end | ADR 0132, the exit of the build |
|
||||||
|
|
||||||
|
## What this does not settle
|
||||||
|
|
||||||
|
- An automated switch on exhaustion (§5).
|
||||||
|
- Whether an OAuth refresh token is single-use; the lab measures it, and §3 holds either way.
|
||||||
|
- How the mesh's own session and a worker read their token on a node once those exist
|
||||||
|
([to-be 15](15-the-agent-session.md), [ADR 0003](../../02-DECISIONS/0003-agents-are-persistent-employees.md)):
|
||||||
|
the agent module on that node is their local source, and the reading is theirs to design.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the decision
|
||||||
|
- [36 — The operator's agent on a machine](36-the-operators-agent-on-a-machine.md) — the consumer on every node
|
||||||
|
- [14 — Model access](14-model-access.md) — the vendor-blind provision this sits beside
|
||||||
|
- the predecessor's `claude-licences` module: the lease, the floor, the cadence, the cooldown, the identity guard — read 2026-10-02
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
status: open
|
||||||
|
opened: 2026-10-02
|
||||||
|
located-in:
|
||||||
|
- mesh-controller
|
||||||
|
fixed-by: 02-DECISIONS/0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md
|
||||||
|
amended-design:
|
||||||
|
---
|
||||||
|
|
||||||
|
# 201 — A push recreated the controller at a digest older than the seat row its successor had written
|
||||||
|
|
||||||
|
## What was observed
|
||||||
|
|
||||||
|
2026-10-02, two merges a minute apart on the control node: one to the controller, adding a verb to the
|
||||||
|
controller seat's row; one to the host, adding a container field. Each made a plan. The controller's plan
|
||||||
|
built and rolled the new controller, which started, widened its seat row with the new verb, and ran. The
|
||||||
|
host's plan then pushed the control node with the controller digest it had recorded when it was made —
|
||||||
|
the previous build — and recreated the controller container on it. The older binary read the row, found
|
||||||
|
a verb it could not run, and refused to start:
|
||||||
|
|
||||||
|
```
|
||||||
|
mesh-controller: the mesh-controller seat's row declares "command", which this control plane
|
||||||
|
cannot run: "command" is not a verb the mesh-controller seat serves
|
||||||
|
```
|
||||||
|
|
||||||
|
A crash loop followed for ten minutes: nothing answered on the bus, and no build was dispatched, since
|
||||||
|
the controller is what fills the builder's queue. Recovery was the mesh's own binary run once from the
|
||||||
|
newer image, outside the service, to push the control node again; the push sent the newer digest and
|
||||||
|
the controller came up.
|
||||||
|
|
||||||
|
## Why it matters beyond this instance
|
||||||
|
|
||||||
|
The row is the store's and the binary follows it ([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md));
|
||||||
|
a start-up check that refuses a row the binary cannot serve is right, and was built after the outage of
|
||||||
|
2026-09-27 for exactly this reason. What is wrong is a plan sending a controller older than the one that
|
||||||
|
wrote the row. A plan is made at a moment and sends what it recorded ([ADR 0162](../../02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md));
|
||||||
|
for every other module an older digest is a brief regression a later push corrects. For the controller
|
||||||
|
it is the mesh losing its voice, and the correction needs a hand, because the thing that would correct
|
||||||
|
it is the thing that is down. Two plans that overlap will happen again whenever two people merge within
|
||||||
|
a minute.
|
||||||
|
|
||||||
|
## What a fix would have to do
|
||||||
|
|
||||||
|
Either of two, and the first is the smaller:
|
||||||
|
|
||||||
|
- A push never sends a controller digest older than the one the running controller is — the controller
|
||||||
|
knows its own digest and refuses to downgrade itself through a plan, saying so in the plan's words.
|
||||||
|
- Or the start-up check tolerates a row wider than the binary while a roll-out is in flight, and serves
|
||||||
|
what it can. Weaker: it makes the row and the binary disagree on purpose, which is what the check
|
||||||
|
exists to refuse.
|
||||||
|
|
||||||
|
Until one is built: do not merge a controller change while another plan is rolling, and after merging
|
||||||
|
one, wait for `node show` on the control node to report the new controller before merging anything else.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0162](../../02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
|
||||||
|
- mesh-controller `cmd/mesh-controller/seatverbs.go` (`seatToolHandlers`, the start-up check), `cmd/mesh-controller/push.go`
|
||||||
|
|
||||||
|
## Half of it is closed, 2026-10-02
|
||||||
|
|
||||||
|
[ADR 0185](../../02-DECISIONS/0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md) takes
|
||||||
|
the outage out of it: a control plane behind its seat's row now serves every verb it can run, says
|
||||||
|
which it cannot, and answers the reason when one of those is called. The same race today would cost
|
||||||
|
the verbs the newer build added, for as long as the older binary is in place, and the ordinary
|
||||||
|
"this machine is behind" machinery would put the newer one back without a hand.
|
||||||
|
|
||||||
|
**The race itself is still open**, and this report stays open for it. What was established while
|
||||||
|
closing the other half, so the next reader does not redo it:
|
||||||
|
|
||||||
|
- Composing and sending are serialised per machine by a session advisory lock in the store, so two
|
||||||
|
control planes cannot compose one machine's declaration at the same time. The stale content did
|
||||||
|
not come from two concurrent composes.
|
||||||
|
- A container's image is resolved into the module's manifest when it is *built*, and a push composes
|
||||||
|
from the catalogue as it is at that moment, under the hold. So a compose that ran after the build
|
||||||
|
was taken in could not have named the older image.
|
||||||
|
- The declaration's sequence orders arrival and nothing else (the numbering of
|
||||||
|
[issue 107](../107-a-declaration-carries-no-order/00-report.md)); it cannot tell a later send
|
||||||
|
carrying earlier content from a later send carrying later content. The host refuses a declaration
|
||||||
|
numbered below the last it applied, and both of these were above it.
|
||||||
|
- The machine's own journal shows the two applies ten seconds apart and which replaced what; it does
|
||||||
|
not record which image each declaration named, which is the one fact that would settle it. A host
|
||||||
|
that recorded the digest it was told, per apply, would have answered this in a minute.
|
||||||
|
|
||||||
|
So the trigger is not yet pinned, and guessing at the push path is the most expensive place in the
|
||||||
|
mesh to guess. The fix the report first suggested — a push never sending a control plane a digest
|
||||||
|
older than the one that machine reports running — closes the class without needing the trigger, and
|
||||||
|
is now a correctness nicety rather than the difference between a working mesh and a dead one.
|
||||||
Reference in New Issue
Block a user