Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9197b99153 | ||
|
|
bc51d8e7aa | ||
|
|
85c6db0600 | ||
|
|
9d18968323 | ||
|
|
127675e2da | ||
|
|
0e87aad949 | ||
|
|
2a501af0eb | ||
|
|
bd7fc40099 | ||
|
|
93d4d29b72 | ||
|
|
6ff2440e5d | ||
|
|
65c3315a14 | ||
|
|
854341d4f0 | ||
|
|
d444458bf6 | ||
|
|
dd8b15a0df | ||
|
|
02b4ca9bec | ||
|
|
7cd5b37afe | ||
|
|
aac0da9c0c | ||
|
|
57b818b669 | ||
|
|
33c10aa85a | ||
|
|
e5042e1e7a | ||
|
|
2580fb6839 | ||
|
|
4a8a378ef9 | ||
|
|
3f48e685cc | ||
|
|
1e0b9ee11f | ||
|
|
1faa63b2d5 | ||
|
|
3a359bf99e | ||
|
|
ec6d106af8 | ||
|
|
e2b4f3a5c4 | ||
|
|
dfb2817fe7 | ||
|
|
12742e3a3f | ||
|
|
5b00bdc7af | ||
|
|
d5a96e5c63 | ||
|
|
73e6400802 | ||
|
|
f144ad7be4 | ||
|
|
8394a7bfb1 | ||
|
|
6ac936f170 | ||
|
|
3af4179755 | ||
|
|
dd04729ec5 | ||
|
|
2e5f40c9fa | ||
|
|
2f41b4124d | ||
|
|
9cc00d57ea | ||
|
|
438162b5a5 | ||
|
|
ab1bd5598e | ||
|
|
3f3fb99219 | ||
|
|
3afe619531 | ||
|
|
502cf4839b | ||
|
|
0ba68c154e | ||
|
|
460793af1c | ||
|
|
25de331e9b | ||
|
|
ed5ddcdef6 | ||
|
|
e4f80cc3ce | ||
|
|
d2689c0f86 | ||
|
|
719aa6bd62 | ||
|
|
ca13f59c88 | ||
|
|
f8a0402485 | ||
|
|
9016d88d54 | ||
|
|
6e5dfd2ab8 | ||
|
|
872f20d51f | ||
|
|
550453c5db | ||
|
|
b7aebedc2d | ||
|
|
27b2d30441 | ||
|
|
82fa5f79ea | ||
|
|
f6668d76d6 | ||
|
|
f5d54db7aa | ||
|
|
bcf010886d | ||
|
|
2eba399e1e | ||
|
|
d227ed12d2 | ||
|
|
8712d666bf | ||
|
|
61e70b9395 | ||
|
|
de032e704c | ||
|
|
0c2eae07c5 | ||
|
|
f23a71e0d7 | ||
|
|
9c13c89fa3 | ||
|
|
2db0ea268d | ||
|
|
8d83d94659 | ||
|
|
a8ffc2b94b | ||
|
|
1dcbdae1c4 | ||
|
|
c3ec48f85c | ||
|
|
72eda923c7 | ||
|
|
c4fedcdbe3 | ||
|
|
0bf70ee8b4 | ||
|
|
947b85af5e | ||
|
|
ac6c306df3 | ||
|
|
96df3ccc88 | ||
|
|
bc64c5c187 | ||
|
|
be4b5777b8 | ||
|
|
d882b3568c | ||
|
|
a3523617d3 | ||
|
|
6b4da63261 | ||
|
|
0231974226 | ||
|
|
92c029d10e | ||
|
|
2a60da821d | ||
|
|
e1b0bbde91 | ||
|
|
8ca09c70d6 | ||
|
|
db71b83711 | ||
|
|
9143d0b7c1 | ||
|
|
24a51a8e53 | ||
|
|
f0d7f91d90 | ||
|
|
8578a06ca8 | ||
|
|
86083f9c9d | ||
|
|
63d328147e | ||
|
|
fead0ea440 | ||
|
|
df503d1cff | ||
|
|
c2fc829822 | ||
|
|
8d8e5c9a7e | ||
|
|
3ed55a3420 | ||
|
|
affba60b79 | ||
|
|
9ddbc4c68e | ||
|
|
a972db91f0 | ||
|
|
029698fdc8 | ||
|
|
8184585213 |
@@ -78,6 +78,12 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
||||
mesh-scoped exclusive claim is how the mesh says "there is one of me". A foundation seat is
|
||||
named after the server it guards: the `mesh-controller`, `postgres` and `lavinmq` modules claim
|
||||
the `mesh-controller`, `mesh-store` and `mesh-broker` seats ([ADR 0079](../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)).
|
||||
- **depends on a seat** — a module needing a seat held on its node by some module, without holding
|
||||
it. Derived from the resources it declares, never stated: a `service` depends on
|
||||
`node-service-manager`, a `package` on `node-package-manager`, a `container` on
|
||||
`node-container-runtime` ([ADR 0207](../02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)).
|
||||
Not a claim: a module **claims** a seat it holds and **declares** resources. Nothing claims a
|
||||
package.
|
||||
- **provision** — a service one module `provides` and others `require`; the mesh resolves a provider
|
||||
and wires the two with an endpoint and a credential. A provision is a service you offer, a seat
|
||||
is a role you occupy, and the two meet where a seat delivers a provision: occupying the seat is
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
status: active
|
||||
initiated: 2026-10-03
|
||||
touches: [the seats, the seat protocol, the controller's ownership check, 03-DESIGN/01-to-be/26-the-seats.md]
|
||||
---
|
||||
|
||||
# 023 — A seat protocol that defines what its holder owns
|
||||
|
||||
## What is investigated
|
||||
|
||||
**A seat is a definition — a protocol — and a module occupies it by implementing that protocol.**
|
||||
Today the protocol is what the holder accepts, emits and serves (ADR 0118, 0129, 0132): its verbs, as MCP
|
||||
tool definitions. This asks whether the protocol should also name the **files and directories the
|
||||
holder owns**, so that occupying the seat means owning them: `node-resolver-config` owns
|
||||
`/etc/resolv.conf`, `node-hosts-file` owns `/etc/hosts`, the intrusion prevention owns its jail file.
|
||||
|
||||
The direction is the protocol's, not the holder's: the seat states what any holder must own; a module
|
||||
that wants the seat must declare those paths among its resources, or the controller refuses the claim
|
||||
as not implementing the seat. Two seats may not name one path.
|
||||
|
||||
## Why
|
||||
|
||||
Who owns a singular file is today answered by reading every manifest, and enforced only after the fact,
|
||||
when two modules on one machine both declare the same path. The question *which module owns
|
||||
`/etc/resolv.conf`?* came up on 2026-10-03 with no place to look it up. A seat that names the path answers
|
||||
it from the seat table, before any module is written, and makes "implements the seat" checkable.
|
||||
|
||||
## What it touches
|
||||
|
||||
- The seat definition and its table (ADR 0122) — a new part of the protocol.
|
||||
- The controller's ownership check (`checkResources`), which already refuses two modules owning one path.
|
||||
- Every node seat that is really about a file: `node-resolver-config`, `node-hosts-file`
|
||||
([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)),
|
||||
`node-intrusion-prevention`, `node-packet-filter`.
|
||||
|
||||
Raised by the operator during the resolver work of ADRs 0194–0199 and parked there so that work was not
|
||||
widened by it.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
status: graduated
|
||||
initiated: 2026-10-04
|
||||
touches: [the bus, what a module declares, the tool runtime, the SDK, the bus grants, 03-DESIGN/01-to-be/25-the-bus-on-nats.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md]
|
||||
became: [02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md, 03-DESIGN/01-to-be/25-the-bus-on-nats.md]
|
||||
---
|
||||
|
||||
# 024 — State a module keeps on the bus
|
||||
|
||||
## What is investigated
|
||||
|
||||
A place on the bus where a module's own code keeps **current state** — not history — that every
|
||||
machine sees, including a machine that joins after the state was written: put, get, delete, list and
|
||||
watch, reached through the node's runtime the way a bundle already publishes, asks and subscribes
|
||||
([ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)).
|
||||
On NATS that is a key-value bucket. The questions are what a module declares, who creates the
|
||||
bucket, what the grants are, what the runtime's verbs are, and what may never be stored.
|
||||
|
||||
## Why
|
||||
|
||||
The mesh carries two kinds of module traffic and a third is missing.
|
||||
|
||||
- **Events** land in the EVENTS stream: limits retention, seven days, ten thousand messages per
|
||||
subject, a durable consumer per consuming module that replays what it missed. Never a secret
|
||||
([design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10).
|
||||
- **Requests** are core request/reply — tool calls, a bundle's `mesh/ask` — and are kept nowhere.
|
||||
|
||||
Neither is *the current value of something*. Two cases from the first module that needs it, the
|
||||
operator's agent on a machine ([design 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)):
|
||||
|
||||
1. **An MCP server registered for every machine.** Registering emits an event every machine's copy
|
||||
of the module consumes. A machine the module is assigned to *after* the registration has no
|
||||
durable consumer yet — the consumer is created at assignment — so it never hears of it. Wanted
|
||||
instead: one entry per server, for every machine or for one; every machine reads the whole current
|
||||
set when it starts and watches for changes; unregistering is a delete; any machine can list it.
|
||||
2. **Which licence a machine is bound to** ([design 39](../../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md)).
|
||||
As events, a machine that was off for a day replays every rotation since and asks for a token
|
||||
after each. It needs only the latest binding and its generation. The token itself stays on
|
||||
request/reply and is never stored.
|
||||
|
||||
The design already expects this. [Design 25](../../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1:
|
||||
"conditions and observed state in key-value buckets that anything may watch".
|
||||
[Research 017](../017-a-mesh-that-heals-itself/01-the-intended-behaviour.md) wants a provisioner's
|
||||
"what I applied" and a rotation's step kept in one rather than in memory. Nothing implements it.
|
||||
|
||||
## What exists, measured 2026-10-04
|
||||
|
||||
| | fact | where |
|
||||
|---|---|---|
|
||||
| streams | five kinds of mesh stream: CONTROL (work queue), NODES and ASSIGNMENTS (last per subject), EVENTS (limits: 7 days, 10 000 per subject), one work queue per seat that accepts | the controller's broker streams |
|
||||
| the state relationship | [design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 already names *state* — 1:1, last per subject — and says it is "declared: the mesh's own". Two streams use it, both written by the controller. No module can declare it | design 32, the controller |
|
||||
| key-value buckets | none, anywhere | all four code repositories |
|
||||
| the runtime's bus verbs | `mesh/publish`, `mesh/ask`, `mesh/subscribe`; delivery back to the bundle is `mesh/event` | the runtime's launcher |
|
||||
| the runtime's principal | one bus user per machine carries every assigned module; its grant is the union of theirs. That one module's code does not act as another is the runtime's to keep: it publishes under the module's own name by construction | the controller's grant composition, the runtime's bus |
|
||||
| what a bundle is issued | a membership per assignment, last per subject, read directly by the runtime: where it serves, where it emits, what it reaches | ADR 0160 |
|
||||
| who creates bus objects | the controller only — mesh streams on every raise, a seat's stream at registration, a module's consumer at assignment. No module reaches the JetStream API | design 25 §3 |
|
||||
|
||||
### What a key-value bucket needs from a grant, against a real server
|
||||
|
||||
Measured against nats-server 2.10 with the Go client the runtime already uses, a bucket created by
|
||||
an unrestricted user and used by two users holding only the subjects below (`B` is the bucket):
|
||||
|
||||
| operation | subject published | writer | reader |
|
||||
|---|---|---|---|
|
||||
| bind to the bucket | `$JS.API.STREAM.INFO.KV_B` | yes | yes |
|
||||
| get | `$JS.API.DIRECT.GET.KV_B.>` | yes | yes |
|
||||
| put, delete | `$KV.B.>` | yes | **refused** |
|
||||
| list keys, watch | `$JS.API.CONSUMER.CREATE.KV_B.>` — an ordered, ephemeral consumer | yes | yes |
|
||||
| stop a watch cleanly | `$JS.API.CONSUMER.DELETE.KV_B.>` | yes | yes |
|
||||
| answers | its own inbox, which every principal already subscribes | — | — |
|
||||
|
||||
*Checked again once built, 2026-10-04:* the grants the controller composes for two machines' runtimes —
|
||||
one carrying the owner, one only a reader — were loaded into a server as composed, and each operation
|
||||
was run as each runtime's user. The owner's did all of them; the reader's read, listed and watched,
|
||||
and its put and delete were refused by the server.
|
||||
|
||||
Three things the measurement showed that reading the documentation would not have:
|
||||
|
||||
1. **A refused put is not an error to the caller; it is a timeout.** The server reports the
|
||||
permission violation asynchronously, on the connection, and the client waits out its deadline
|
||||
for an acknowledgement that never comes. So a runtime that relies on the grant alone tells a
|
||||
bundle "timed out" for "you may not write this" — it must refuse first, from what the module was
|
||||
issued, with the reason.
|
||||
2. **A watch's current values include deletions.** A key deleted earlier arrives among the initial
|
||||
values as a delete marker, before the end-of-current marker. A bundle asking "what is there now"
|
||||
must not be handed those.
|
||||
3. **Without the consumer-delete grant, stopping a watch hangs** until its deadline, and the
|
||||
ephemeral consumer lingers on the server until it times out by itself.
|
||||
|
||||
### Whether the events shape is enough instead
|
||||
|
||||
Honestly compared, because a new primitive is a cost:
|
||||
|
||||
- **EVENTS cannot be made last-per-subject for some subjects.** Retention is per stream, and
|
||||
JetStream refuses a second stream overlapping the first (verified and recorded in design 32 §3).
|
||||
A state subject inside `mesh.mod.*.event.>` keeps EVENTS' seven days: a licence binding unchanged
|
||||
for a week disappears.
|
||||
- **A separate last-per-subject stream per module** is possible — it is exactly what a key-value
|
||||
bucket *is* on the server: a stream with one message per subject, a rollup for purge, and direct
|
||||
reads. Building it by hand gives up the client's get, list, delete and watch, which are the
|
||||
operations both cases need, and would be the mesh writing NATS's own key-value layer again.
|
||||
- **Consumers are the wrong reader.** A durable consumer per reading module is created at
|
||||
assignment and replays from where it is; state wants "everything current, now, then changes",
|
||||
which an ordered ephemeral consumer from the last value per subject gives and a durable does not.
|
||||
|
||||
So key-value is not a convenience over events; it is the state relationship design 32 already
|
||||
names, opened to modules.
|
||||
|
||||
## Questions, and what this effort proposes
|
||||
|
||||
1. **What a manifest says.** `state` names the buckets a module owns, by local name — every
|
||||
instance of the module may write them and read them. `reads` names another module's bucket as
|
||||
`<module>.<name>`, read-only. Names only, never a bucket or subject (design 32 §1). A bucket's
|
||||
options — how many past values it keeps, how long a value lives — are the owner's to declare,
|
||||
the way a seat declares its own retention (design 32 §3).
|
||||
2. **Scope.** One bucket per module per name, mesh-wide. A key may carry a machine by the module's
|
||||
own convention (`all.<server>`, `<machine>.<server>`). A bucket per machine was considered and
|
||||
not proposed: "list every server for every machine" becomes a walk over buckets, and the grant
|
||||
could only narrow writes, which nothing asked for — every instance of the owner already writes.
|
||||
3. **Who creates the bucket.** The controller, from the catalogue, on every raise — a bucket exists
|
||||
from registration, like a seat's stream, so a reader can watch before the owner is assigned
|
||||
anywhere. Never a module.
|
||||
4. **The runtime's verbs.** `mesh/state.get`, `mesh/state.put`, `mesh/state.delete`,
|
||||
`mesh/state.keys`, `mesh/state.watch`, each naming the bucket as the module named it. A watch
|
||||
is answered once the current values are on their way, then each change is delivered to the
|
||||
bundle as a `mesh/state` request it answers — current values first (no deletions among them), an
|
||||
end-of-current marker, then changes. A child that restarts watches again, as it subscribes
|
||||
again. The runtime refuses, with the reason, a bucket the module was not issued, and a write to
|
||||
one it only reads.
|
||||
5. **Secrets.** None in a bucket, sealed or not: a bucket is a stream (design 32 §10). Sealed values
|
||||
are plain base64 and cannot be recognised, so the mechanical check is partial and said to be: the
|
||||
runtime refuses a value carrying a field whose name says it is a credential (`password`,
|
||||
`secret`, `token`, `authorization`, …), which catches the ordinary mistake and not a determined
|
||||
one. For the first consumer this has a concrete consequence: an MCP server registered with an
|
||||
authorisation header keeps that header out of the bucket.
|
||||
6. **History, lifetime, size.** One value per key unless the owner says more; no expiry unless it
|
||||
says one; a value at most 256 KiB and a bucket at most 64 MiB, the mesh's caps rather than a
|
||||
module's. **A bucket outlives its module's assignment** — what a module stored is data, and data
|
||||
outlives what declared it ([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md));
|
||||
unassigning is not cleaning up. A bucket whose declaration is gone is reported, never removed.
|
||||
7. **Events or state.** State (above).
|
||||
|
||||
## The work, once decided
|
||||
|
||||
1. A decision record, then design 32 (*state* becomes a relationship a module declares) and design
|
||||
25 (key-value buckets are part of the bus) amended.
|
||||
2. The controller: the manifest's two words and their registration check; buckets asserted on every
|
||||
raise; the grants for owners' and readers' runtimes; the buckets issued in each membership.
|
||||
3. The runtime: the five verbs, the watch delivery, the refusals; tested against a real server.
|
||||
4. The SDK, TypeScript and Go: a small state surface over the verbs.
|
||||
5. Proved on a running mesh with one small module, then handed to the operator's agent, whose
|
||||
registered servers move from events to a bucket.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
status: graduated
|
||||
initiated: 2026-10-04
|
||||
touches:
|
||||
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
|
||||
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
|
||||
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
|
||||
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
|
||||
- 02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md
|
||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
||||
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
|
||||
- 03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md
|
||||
- 03-DESIGN/01-to-be/37-the-operators-machine.md
|
||||
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
|
||||
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
|
||||
became:
|
||||
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
|
||||
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
|
||||
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
|
||||
- 03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md
|
||||
---
|
||||
|
||||
# 025 — How a module plugs into the operator's shell
|
||||
|
||||
## What is investigated
|
||||
|
||||
The shell module writes the mesh's part of the account's shell startup file. But the shell is not the
|
||||
only module that needs a line there. A prompt theme loads itself from it. A language version manager
|
||||
sets a variable and sources its loader. A toolchain puts its directory on `PATH`. A desktop module
|
||||
names the browser. Today all of these sit in one hand-written file, and the shell module as written
|
||||
carries some of them in its own block and loads others only "if a module placed them". Nothing says
|
||||
how they get placed.
|
||||
|
||||
This effort asks four things:
|
||||
|
||||
1. **How a module contributes to the shell**: what it declares, who composes it, and in what order it
|
||||
lands.
|
||||
2. **Where the environment lives.** Variables and `PATH` entries are facts about the account, not
|
||||
lines of one shell's syntax. They should reach every shell (interactive or not), the login shell's
|
||||
`execute` verb, and programs a graphical session starts.
|
||||
3. **Where the operator's own lines go,** so that assigning the shell module loses nothing the
|
||||
machine does today.
|
||||
4. **Which part of a file the mesh owns.** ADR 0174 calls the kept region the operator's; the host and
|
||||
to-be 38 implement the inverse (the mesh owns a marked block, and everything outside it is the
|
||||
operator's). The record this becomes says which.
|
||||
|
||||
## Why
|
||||
|
||||
Rolling out the shell module (to-be 38 WP5) was stopped on 2026-10-04 after a review of what assigning
|
||||
it would do. Measured in [01](01-what-the-shell-file-holds-today.md):
|
||||
|
||||
- Every machine carries the same predecessor-written startup file, so the module's block would be
|
||||
appended after its own older copy and everything would run twice.
|
||||
- The block drops lines the machines rely on today.
|
||||
- Nothing installs the prompt theme or the plugins the block loads.
|
||||
- The `execute` verb runs a non-interactive login shell, which never reads the file the block is
|
||||
written into.
|
||||
|
||||
The operator's direction: other modules must be able to plug themselves into the shell; the prompt
|
||||
becomes its own module; assigning the shell module must lose no functionality; and the environment,
|
||||
`PATH` above all, needs an answer of its own.
|
||||
|
||||
## What it touches
|
||||
|
||||
- The manifest. A contribution to the shell is either a new use of the existing `contributes` /
|
||||
`receives` pair or a new gathered field like `jails` (to-be 31).
|
||||
- The controller's composition, if the controller assembles the text.
|
||||
- The `login-shell` seat (ADR 0176): what a holder must do with what is contributed to it, and
|
||||
whether a module or the mesh declares the seat. Possibly a new seat for the environment, beside it
|
||||
and beside the service manager's (ADR 0177). Research 023 asks the related question of a seat
|
||||
naming the files its holder owns.
|
||||
- ADR 0174's wording of the kept region, and ADR 0182's classification of the paths under a home.
|
||||
- The zsh module, and the modules this makes possible: an environment module, the prompt, a version
|
||||
manager, a toolchain.
|
||||
|
||||
## Where it stands
|
||||
|
||||
The operator proposed a separate **environment module**: one module, holding a mesh seat of its own,
|
||||
that alone writes the account's environment. It writes a file that shells source and the service
|
||||
manager's user environment, from the variables and `PATH` entries every other module contributes to
|
||||
it. That is the starting position for the environment ([02](02-how-a-module-plugs-in.md) §1, option
|
||||
E6). It leaves the shell's contribution as shell code only (§2), addressed to the `login-shell` seat,
|
||||
which moves into the mesh's own seat set beside the new `node-environment` (§6).
|
||||
|
||||
Graduated on 2026-10-04 with one change from the starting positions: the controller, not the
|
||||
environment module's own code, renders the environment into the module's files, so that the result
|
||||
is in the declaration before a machine applies it ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
|
||||
option 6b).
|
||||
|
||||
## Documents
|
||||
|
||||
- [01 — What the shell file holds today](01-what-the-shell-file-holds-today.md): evidence.
|
||||
- [02 — How a module plugs in](02-how-a-module-plugs-in.md): the options and the starting position.
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# 01 — What the shell file holds today
|
||||
|
||||
Measured 2026-10-04 on the four machines of one installation: two servers and two workstations. All
|
||||
four have the account's login shell set to zsh, zsh installed from the distribution, and a
|
||||
predecessor-written startup file. The predecessor is retired, so nothing manages these files any more.
|
||||
|
||||
## The startup file is the same everywhere
|
||||
|
||||
The account's `~/.zshrc` is **byte-identical on all four machines**: 102 lines, one checksum.
|
||||
`~/.zshrc.local`, which the last line of `~/.zshrc` sources, comes in **two variants**: one shared by
|
||||
both servers, and one shared by both workstations. So the "per-machine" part is really a
|
||||
per-*kind*-of-machine part.
|
||||
|
||||
The predecessor produced these from one module with two *flavors*: a prompt flavor and an
|
||||
autocomplete flavor, each of which swapped in a different local file. Its install hook also:
|
||||
|
||||
- cloned the prompt theme and three plugins from their upstream repositories into `~/.zsh/`;
|
||||
- installed fonts;
|
||||
- changed the login shell.
|
||||
|
||||
On the workstations the theme and plugins are still on disk, left over and now owned by nothing. The
|
||||
servers have none of them.
|
||||
|
||||
## What the 102 lines are
|
||||
|
||||
Sorted by who should own each line once the machine is modules:
|
||||
|
||||
| Lines today | What they are | Natural owner |
|
||||
|---|---|---|
|
||||
| `EDITOR`, `VISUAL`, `XDG_CONFIG_HOME`, `PATH` gaining `~/.local/bin` and two script directories | the account's environment | the shell's default, or the environment itself |
|
||||
| `PATH` gaining a toolchain's directory | environment, for one tool | the toolchain's module |
|
||||
| a version manager's directory variable plus sourcing its loader | environment *and* shell code | the version manager's module |
|
||||
| two variables naming the operator's own script library | environment, the operator's own | the operator |
|
||||
| a variable that turns off an agent's terminal-title handling | environment, for one tool | the agent's module |
|
||||
| the terminal title hook, keybindings, `dircolors`, the `ls`/`grep` aliases, `ll`/`la`/`l`, a container-run alias, two disk-usage functions, two port aliases | interactive shell behaviour | the shell's default |
|
||||
| the prompt's instant-prompt cache, the theme, the prompt's own configuration file | shell code, order-sensitive (instant prompt first) | the prompt module |
|
||||
| autosuggestions, syntax highlighting (and, unloaded, an autocomplete plugin on disk) | shell code, order-sensitive (syntax highlighting last) | a plugin module, or the prompt module |
|
||||
| sourcing `~/.zshrc.local` | the operator's hook | the operator |
|
||||
|
||||
The workstation variant of the local file adds:
|
||||
|
||||
- more environment: a desktop toolkit theme, a file manager's plugin list, `BROWSER`, `VISUAL`
|
||||
overridden to a graphical editor, a language toolchain's binary directory on `PATH`;
|
||||
- two pieces of shell code: one that pads the prompt to the bottom of the terminal under a display, and
|
||||
one that sources a function file another module places;
|
||||
- a hook sourcing a further per-node file.
|
||||
|
||||
The server variant holds only that last module-placed source line.
|
||||
|
||||
**Count:** a workstation runs 65 non-comment lines from the two files (53 shared, 12 local); a server
|
||||
runs 54. Of a workstation's 65:
|
||||
|
||||
- about a quarter (15) are environment;
|
||||
- about half are interactive defaults no other module cares about;
|
||||
- the remaining quarter is other modules' code and hooks (a prompt, plugins, a version manager, an
|
||||
agent's functions), loaded from the shell file only because there was nowhere else to put it.
|
||||
|
||||
## The shell module as written
|
||||
|
||||
The `zsh` module of to-be 38 WP5 (catalogue change, unmerged):
|
||||
|
||||
- writes one block, appended at the end of `~/.zshrc`, holding a subset of the shared file:
|
||||
- its environment lines, minus the toolchain directory, the version manager and the agent variable;
|
||||
- the title hook, keybindings and the most common aliases, minus the port aliases;
|
||||
- guarded `source` lines for the theme and two plugins *if present*;
|
||||
- the source of `~/.zshrc.local`.
|
||||
- assigned to any of the four machines, appends that block after the identical lines already there, so
|
||||
every line in it runs twice, `~/.zshrc.local` included.
|
||||
- on the servers, the guarded prompt lines find nothing; nothing installs the theme anywhere.
|
||||
|
||||
## Which startup file reaches what
|
||||
|
||||
zsh's startup order, and what each path through it reads:
|
||||
|
||||
| started as | reads |
|
||||
|---|---|
|
||||
| interactive login (a console, ssh with a terminal) | `.zshenv`, `.zprofile`, `.zshrc`, `.zlogin` |
|
||||
| interactive non-login (a new terminal window) | `.zshenv`, `.zshrc` |
|
||||
| non-interactive login: `zsh -lc …`, what the `execute` verb runs | `.zshenv`, `.zprofile`, `.zlogin`, **not** `.zshrc` |
|
||||
| non-interactive: a script, `ssh host command` | `.zshenv` only |
|
||||
|
||||
So an environment written into `.zshrc` reaches neither `execute` nor a script. The distribution's
|
||||
system-wide login profile, which zsh's system `zprofile` sources, only ever *appends* to `PATH` when an
|
||||
entry is missing. An entry the account's `.zshenv` puts first therefore survives a login.
|
||||
|
||||
A graphical session's programs (a launcher, a bar, a window manager's key bindings) are started from the
|
||||
display manager and the service manager, not from a shell, and read none of these files. The service
|
||||
manager's own place for the account's environment is `~/.config/environment.d/`. Today it holds nothing
|
||||
on any of the four machines, so a program launched from the window manager does not see `PATH` entries
|
||||
that a terminal does.
|
||||
|
||||
## What the mesh already has for "many modules, one file"
|
||||
|
||||
Measured over the catalogue's 69 module definitions:
|
||||
|
||||
| mechanism | used by | shape |
|
||||
|---|---|---|
|
||||
| `contributes` / `receives` | 28 contribute, 15 receive | A consumer contributes **facts** keyed by a requirement. The provider receives all of them as one file in the mesh's own format, and **renders them itself**. "The controller does not know what a reverse proxy is." |
|
||||
| `listens` / `filtering` | 40 declare listens, 1 composes | The controller derives the whole firewall rule set from every module's ports and writes it where the holder asks. |
|
||||
| `jails` / `jailing` | 3 declare, 1 composes | Each module supplies its jail **in the tool's own format**. The controller assembles them, sorted, into the one file the holder names. |
|
||||
| `into: block` on a file | 2 | One module's marked region inside a file something else owns. Text outside the region is kept byte for byte. Placement is at the end, or at the start. |
|
||||
|
||||
None of these is a contribution of shell code or of environment today.
|
||||
@@ -0,0 +1,197 @@
|
||||
# 02 — How a module plugs in
|
||||
|
||||
Six questions, taken one at a time: the environment, shell code, the operator's own lines, order,
|
||||
who renders, and what a contribution is addressed to. Each has the options weighed and a starting
|
||||
position. The positions were set with the operator on 2026-10-04 and are what this effort tests, not
|
||||
what it has decided.
|
||||
|
||||
## 1. The environment: variables and `PATH`
|
||||
|
||||
A variable or a `PATH` entry is a fact about the account. It holds whichever shell is the login shell,
|
||||
and it is wanted by:
|
||||
|
||||
- every shell, interactive or not;
|
||||
- the login shell's `execute`;
|
||||
- a graphical session's programs.
|
||||
|
||||
[01](01-what-the-shell-file-holds-today.md) measures that `.zshrc` reaches only the first kind, and
|
||||
only interactively.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| E1 | Each module writes lines into the shell's rc file (today) | nothing new | misses `execute`, scripts and the graphical session; written in one shell's syntax, so a second shell module starts over |
|
||||
| E2 | A module contributes environment facts (a variable and its value; a `PATH` entry and its position) **to the login shell**. The holder renders them into its shell's always-read file (`~/.zshenv` for zsh) | reaches every zsh, `execute` included; a contributor names no path and no shell | the graphical session sees none of it; the environment is tied to which module holds the shell; every shell module reimplements the same rendering |
|
||||
| E3 | E2, and the service-manager holder (ADR 0177) renders the same facts a second time into `~/.config/environment.d/` | the graphical session sees the same `PATH` as the terminal | one fact set, two owners, two renderings that can disagree; the service manager's module gains a duty unrelated to managing services |
|
||||
| E4 | One composed file in `environment.d` syntax, sourced by the shell with export-all | one file, two readers | ties the shell to the service manager's syntax, which is close to POSIX assignments but not equal (its `${VAR:-default}` and quoting rules differ); a value with a space breaks one reader or the other |
|
||||
| E5 | Shells take the environment from the service manager's environment generator, which prints the merged `environment.d` | no file of the shell's at all | every shell depends on the service manager and starts a process on every start; the generator's output is unquoted, so a value with a space breaks it |
|
||||
| **E6** | **An environment module.** A module of its own (working name `node-env`) holds a mesh seat, `node-environment`, and is the only writer of the account's environment. Every module contributes its variables and `PATH` entries to that seat. The holder writes them in each reader's format: a POSIX file of `export` lines that shells source, and the service manager's `~/.config/environment.d/` | the environment no longer depends on which shell holds `login-shell`; one owner and one rendering per format, both from the same facts; the graphical session included without the service manager's module; a contributor addresses "the environment", never a shell; the `PATH` rules (order, de-duplication) live in one module's code, where a test can hold them | one more module and seat, assigned on every node beside the shell; the `login-shell` protocol gains a duty, to source the environment file, which must be written down and checked |
|
||||
|
||||
**Starting position: E6.** It was the operator's proposal on 2026-10-04, and it replaces this
|
||||
document's first position (E2, then E3).
|
||||
|
||||
- The facts are the contribution. Each format is rendered once, by the one module whose subject is the
|
||||
environment.
|
||||
- A shell module's part shrinks to one line in its always-read file: `.zshenv` for zsh, sourcing the
|
||||
environment module's POSIX file. A bash or fish module writes the same line in its own file, and no
|
||||
contributor changes when the login shell does.
|
||||
|
||||
Sketched, for a node with zsh, the environment module, and a toolchain:
|
||||
|
||||
```
|
||||
toolchain ──contributes PATH entry──▶ node-environment ◀──contributes EDITOR, ~/.local/bin── zsh
|
||||
│ (held by node-env)
|
||||
┌──────────────────┴──────────────────┐
|
||||
▼ ▼
|
||||
POSIX export file ~/.config/environment.d/
|
||||
▲ ▲
|
||||
sourced from ~/.zshenv read by the service manager
|
||||
(every zsh, execute too) (the graphical session)
|
||||
```
|
||||
|
||||
The shell module still contributes its own environment (`EDITOR`, `XDG_CONFIG_HOME`, `~/.local/bin` on
|
||||
`PATH`) as a contributor like any other; it does not write those lines itself. Once issue 168 closes,
|
||||
the values a person varies become settings of whichever module contributes them (ADR 0174).
|
||||
|
||||
## 2. Shell code: a prompt, plugins, a version manager's loader
|
||||
|
||||
This *is* one shell's syntax, and order matters: a prompt's instant-prompt cache must run first, and
|
||||
syntax highlighting last.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| S1 | **A contribution of code for one shell** (the shell it is for, the code, a slot), gathered by the controller and placed inside the holder's block in slot order. The same shape as `jails`, which a module supplies in fail2ban's own format and the controller assembles | a contributor names no path; the order is declared and checkable; unassigning the contributor removes its code at the next composition; a node holding fish simply has no zsh code rendered, and the resolver can say so | the controller gains one more gathered field; code for a shell travels in the declaration (in the clear, so no secrets in it, as for any file) |
|
||||
| S2 | **A drop-in directory**: each module places its own `~/.zsh/rc.d/NN-name.zsh`, and the shell's block sources the directory | no controller change; each file is its module's own, removed when undeclared | every contributor hard-codes a path inside the shell module's territory, against ADR 0112's spirit; order is a naming convention nothing checks; nothing ties the file to the shell actually being zsh |
|
||||
| S3 | Contributions as facts the holder renders (`contributes`/`receives` proper) | one mechanism with question 1 | code is not a fact; the holder would only paste it, which is S1 with an extra file |
|
||||
|
||||
**Starting position: S1.** A contribution to the shell carries **only code**, for named shells, each
|
||||
piece in a slot. Variables and `PATH` entries never go here; they go to the environment (§1). So a
|
||||
module touching both makes two contributions:
|
||||
|
||||
- A prompt module contributes zsh code in the first slot, and its own configuration file is its own
|
||||
owned file (ADR 0182).
|
||||
- A version manager contributes its directory variable to the environment, and its loader as code
|
||||
for each shell it supports.
|
||||
- A toolchain contributes a `PATH` entry to the environment and nothing to the shell.
|
||||
|
||||
What has to be settled: what each contribution is *addressed to*. Section 6 covers that.
|
||||
|
||||
## 3. The operator's own lines: the "local override"
|
||||
|
||||
Assigning the shell module must lose nothing the machine does today. That has two halves.
|
||||
|
||||
**What is common is the module's default, not an override.** The startup file is identical on all four
|
||||
machines ([01](01-what-the-shell-file-holds-today.md)). A line every machine has is the shell module's
|
||||
default, or another module's contribution. It is not a local override that a person would keep in step
|
||||
on every machine by hand. Most of today's file therefore moves into the shell module's block and into
|
||||
the contributions above. Little of it stays the operator's.
|
||||
|
||||
**What is the operator's is everything outside the mesh's block.** The host already works this way:
|
||||
|
||||
- the mesh's region is the marked block;
|
||||
- text outside it is kept byte for byte, and checked unchanged;
|
||||
- the region is given back when the module goes.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| O1 | The mesh's block at the **start** of the file; the operator's lines after it | the operator's lines run last and win, which is what an override means; already supported (`at: start`) | a file the operator later rewrites must keep the markers; the host refuses a broken pair rather than guess |
|
||||
| O2 | A named operator region *inside* a file the mesh writes whole (ADR 0174's wording) | the file is entirely the mesh's except one hole | the opposite of what the host implements; a file a person already owns becomes the mesh's |
|
||||
| O3 | Only `~/.zshrc.local`, sourced from the block; `~/.zshrc` the mesh's whole | one obvious place | takes over a file the person owns today; ADR 0182 classifies the shell's own file as *written into*, not owned |
|
||||
|
||||
**Starting position: O1.** `~/.zshrc.local` keeps working because the operator's own lines source it,
|
||||
not because the mesh's block does.
|
||||
|
||||
The record this effort becomes corrects ADR 0174's description of the kept region as a **progressive
|
||||
insight**: the decision stands (a node varies a module by settings or by the operator's own lines,
|
||||
never by an edit), and only its description of which side is marked changes.
|
||||
|
||||
**The one-off migration** is a person's act, listed in the module's documentation (ADR 0182):
|
||||
|
||||
- remove from today's file every line the block or a contribution now carries;
|
||||
- keep the rest below the block.
|
||||
|
||||
Until a prompt module and the other contributors exist, the lines they will carry stay among the
|
||||
operator's own. Nothing is lost at any step.
|
||||
|
||||
## 4. Order
|
||||
|
||||
Order matters only for code. The environment is set before any code runs, because zsh reads
|
||||
`.zshenv` first. `PATH` entries carry their own position (before or after the system's), which the
|
||||
environment module orders, not the shell.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| R1 | Numbers (`10`, `50`, `90`) | familiar | every contributor guesses a number; collisions are silent |
|
||||
| R2 | **A few named slots**, `first` / `normal` / `last`, with the module name breaking ties | the prompt says `first` and highlighting says `last` because that is what they mean; the composed result is the same bytes every time | three slots may not be enough |
|
||||
|
||||
**Starting position: R2.** Inside the shell module's block, the order is:
|
||||
|
||||
1. the line sourcing the environment module's file (in `.zshenv`, so it runs for every zsh; the rest
|
||||
of this list is `.zshrc`);
|
||||
2. the `first` slot;
|
||||
3. the shell module's own defaults;
|
||||
4. the `normal` slot;
|
||||
5. the `last` slot.
|
||||
|
||||
The operator's lines come after the block, as option O1 says.
|
||||
|
||||
## 5. Who renders: the controller or the holder's code
|
||||
|
||||
There are two different renderings, and E6 lets them be answered differently.
|
||||
|
||||
**The environment** is facts rendered into two fixed formats by the one module whose subject they are.
|
||||
|
||||
- The environment module receives the gathered contributions (the `contributes` / `receives` shape:
|
||||
facts in the mesh's own format, rendered by the receiver).
|
||||
- Its own code writes the POSIX file and the `environment.d` file whenever what it receives changes.
|
||||
That is ADR 0182's third class, written by the module's own process, owned by the account,
|
||||
atomically.
|
||||
- The controller learns no shell and no service manager. The `PATH` rules (prepend or append,
|
||||
de-duplicate, keep the system's entries) are ordinary code with ordinary tests.
|
||||
- To settle: what runs that code when the received file changes. The candidates are a host action
|
||||
that restarts on the received file, or a subscription through the runtime (ADR 0198).
|
||||
|
||||
**Shell code** is not facts. It is text in the shell's own syntax, assembled in slot order, which is
|
||||
what the controller already does for fail2ban jails: sort the pieces and concatenate them into the
|
||||
holder's region. The controller assembles; it never interprets the code. This keeps the shell module
|
||||
bundle-free for its files, and keeps the composed result visible in the declaration before a machine
|
||||
applies it.
|
||||
|
||||
## 6. What a contribution is addressed to
|
||||
|
||||
Under E6 there are two addressees: the environment and the login shell.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| A1 | **Seats**: environment facts to `node-environment`, shell code to `login-shell`. Each seat's protocol says what its holder does with what is contributed to it | a contributor depends on a role ("the environment", "the login shell"), never on zsh or on one module; works the same for any holder | `login-shell` today is declared by the zsh module itself (ADR 0126). A second shell module may only claim it, never declare it, and the seat exists only while zsh's definition is registered |
|
||||
| A2 | Requirements the modules provide (`contributes` keyed by them, as the reverse proxy is) | an existing mechanism | a contributor on a node without the provider fails to resolve, though a toolchain's `PATH` entry with no environment module is merely unwritten |
|
||||
|
||||
**Starting position: A1, both seats in the mesh's own seat set** beside the service manager.
|
||||
|
||||
- `node-environment` is new, and is the mesh's from the start.
|
||||
- `login-shell` moves there from the zsh module's definition. A shell is as universal a role as a
|
||||
service manager, and a protocol that now carries duties (render the shell code contributed to it,
|
||||
source the environment file) should not depend on one module's registration.
|
||||
|
||||
Research 023 (a seat's protocol naming what its holder owns) is the general form of this: the
|
||||
environment seat would own the two environment files, and the login-shell seat the shell's
|
||||
startup-file region. The two efforts should not decide it twice.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Whether a contribution may be conditional on a capability: the workstation-only environment (a
|
||||
browser, a toolkit theme) is a desktop module's contribution, which arrives only where that module is
|
||||
assigned. Measured, this may need nothing new.
|
||||
- What a node without the environment module does with environment contributions: refuse them at
|
||||
resolve, or leave them unwritten and say so. The position here is to say so; a missing `PATH` entry
|
||||
is a visible gap, not a broken machine.
|
||||
- Whether the operator's own variables (the script-library paths in [01](01-what-the-shell-file-holds-today.md))
|
||||
are the operator's lines below the shell block, or a kept region of the environment module's file.
|
||||
The first needs nothing new, but reaches only interactive zsh.
|
||||
- How the prompt module and a plugin module divide the plugins. Packaging decides it as much as
|
||||
ownership: the plugins come from upstream repositories, not distribution packages, on these machines.
|
||||
- Whether the `execute` verb should read the interactive file at all once the environment is in
|
||||
`.zshenv`. The position here is no: a non-interactive login shell plus the environment is what a
|
||||
command needs, and the prompt's code should not run for it.
|
||||
- How a contribution reaches a second shell assigned beside the holder, which to-be 38 WP5 names as the
|
||||
first follow-up record. Under A1 a non-holder renders nothing, so the question becomes whether a
|
||||
non-holding shell module may render contributions for interactive use.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
status: active
|
||||
initiated: 2026-10-04
|
||||
touches:
|
||||
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
|
||||
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
|
||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
||||
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
|
||||
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
|
||||
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
|
||||
- 03-DESIGN/01-to-be/37-the-operators-machine.md
|
||||
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
|
||||
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
|
||||
became: []
|
||||
---
|
||||
|
||||
# 026 — The graphical session as modules
|
||||
|
||||
## What is investigated
|
||||
|
||||
The workstations' graphical session as modules of the mesh, at the same level as the shell
|
||||
([to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)): a package, files
|
||||
under the account's home, a seat, and nothing that names a machine. The pieces are:
|
||||
|
||||
- the login manager;
|
||||
- how a session starts and what environment it gets;
|
||||
- the display server (X today, Wayland as a sibling);
|
||||
- the window manager (i3, and sway as its Wayland sibling);
|
||||
- the terminal emulator (xterm);
|
||||
- the session's companions: bar, compositor, launcher, notifier, lock and idle, clipboard,
|
||||
wallpaper, theming, fonts.
|
||||
|
||||
[To-be 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) names this WP7, and says
|
||||
each seat begins with a record naming its holders and verbs. [To-be 37](../../03-DESIGN/01-to-be/37-the-operators-machine.md)
|
||||
§4 leaves one question for the resolver: whether a held seat can gate another's assignment.
|
||||
|
||||
## Why
|
||||
|
||||
The operator asked for the graphical modules next, at the shell's level, and for one consistent
|
||||
experience across machines. Since the predecessor retired, nothing manages the workstations'
|
||||
desktops. Measured in [01](01-what-the-workstations-run.md):
|
||||
|
||||
- Two workstations carry one 983-line predecessor module's output, still byte-identical in its core.
|
||||
- One workstation also carries another machine's hardware fragments.
|
||||
- One runs a session that predates two fixes, with two notification daemons and two portals.
|
||||
- The session's environment is a hand-kept second copy of the account's, beside the one the mesh
|
||||
now writes.
|
||||
|
||||
## How it is approached
|
||||
|
||||
**Adopting is also improving** (the operator, 2026-10-04). A module is not a copy of what a machine
|
||||
does today. Making it is the moment to fix what is broken, drop what is dead, choose the better tool
|
||||
and remove the leftovers. Every module's design lists its improvements over today. **Every module
|
||||
also serves tools,** many of them, for reading, acting and diagnosing; a module that only places a
|
||||
package and a file is unfinished. The tools are catalogued in
|
||||
[026/05](../026-the-graphical-session-as-modules/05-the-tools-each-module-serves.md).
|
||||
|
||||
## What it touches
|
||||
|
||||
- **The seat table:** up to ten node seats.
|
||||
- **The resolver:** a seat held on a node gating another module's assignment.
|
||||
- **The contribution mechanism of ADR 0204:** whether it generalises beyond shells, or whether
|
||||
tools' own drop-in directories serve.
|
||||
- **The host's user-scoped units** (mesh-host #72, still open).
|
||||
- **Settings** for per-machine values (issue 168).
|
||||
- **ADR 0205's archive** for the two pieces the distribution does not package.
|
||||
|
||||
## Documents
|
||||
|
||||
- [01 — What the workstations run](01-what-the-workstations-run.md): evidence.
|
||||
- [02 — The questions and the options](02-the-questions-and-the-options.md)
|
||||
- [04 — Screensaver, displays and menus](04-screensaver-displays-and-menus.md): the lock and idle module, monitor layouts by the monitors' identity, rofi and dmenu behind one launcher seat, the clipboard, fonts
|
||||
- [05 — The tools each module serves](05-the-tools-each-module-serves.md): a first catalogue for the modules of 026 and 027
|
||||
- [03 — What the predecessor taught](03-what-the-predecessor-taught.md): its 128 modules and 3,395 commits, as patterns to keep and failures not to repeat; shared with research 027.
|
||||
@@ -0,0 +1,141 @@
|
||||
# 01 — What the workstations run
|
||||
|
||||
Measured 2026-10-04 on the two workstations of one installation, read-only: a laptop with a hybrid
|
||||
GPU and an internal panel, and a desktop with one GPU and two external monitors. Both run the same
|
||||
predecessor-generated desktop. File equality was checked by checksum across the two machines.
|
||||
|
||||
## How a session starts
|
||||
|
||||
The chain is the same on both:
|
||||
|
||||
1. The login manager (`lemurs`, built from the distribution's user repository, its package now in
|
||||
the official one) runs its X setup script on a virtual terminal.
|
||||
2. That script sources the login shell's profile files, then `~/.xprofile`, then the system's
|
||||
`xinitrc.d` drop-ins, then merges `~/.Xresources`.
|
||||
3. `~/.xprofile` reuses the systemd user manager's bus, then sources `~/.xinitrc`.
|
||||
4. `~/.xinitrc` sets up the session and ends with `exec i3`.
|
||||
|
||||
The login manager's own window-manager entry (`exec startx`) is never reached. Its configuration
|
||||
file uses a format two releases old, and an unmerged newer one sits beside it.
|
||||
|
||||
**What `~/.xinitrc` does**, in order:
|
||||
|
||||
1. Sources the system drop-ins, which import `DISPLAY` and `XAUTHORITY` into the user manager.
|
||||
2. Starts the keyring and exports its ssh socket.
|
||||
3. Exports the session's environment:
|
||||
- `PATH`, with nine entries, one of them a directory that no longer exists;
|
||||
- toolchain variables;
|
||||
- `XDG_CONFIG_HOME` and `XDG_DATA_DIRS` (with flatpak);
|
||||
- five GTK/Qt theme variables;
|
||||
- the desktop's identity (`XDG_CURRENT_DESKTOP`, `XDG_SESSION_DESKTOP`);
|
||||
- three of the operator's own variables.
|
||||
4. Imports an explicit allowlist of ten of those into the user manager and D-Bus activation. It is
|
||||
never `--all`, because:
|
||||
5. a predecessor file of **secrets as environment variables** (package-registry and API tokens) is
|
||||
sourced next.
|
||||
6. Sets the screensaver and display power timeouts, restores the wallpaper, and starts the lock
|
||||
watcher in a respawn loop. It is deliberately not a unit, because it needs the login session.
|
||||
7. `exec i3`.
|
||||
|
||||
**The account's environment, as of today, has three sources that disagree:**
|
||||
|
||||
- this file, for the session;
|
||||
- the mesh's `environment.sh`, for shells
|
||||
([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md));
|
||||
- `~/.config/environment.d/`, for the user manager. It holds the mesh's `50-mesh.conf`, and a
|
||||
predecessor file that **sets `PATH` outright** and sorts after it.
|
||||
|
||||
## The roles, and what fills them
|
||||
|
||||
| role | software | where configured |
|
||||
|---|---|---|
|
||||
| login manager | lemurs | `/etc/lemurs/*` (identical on both, and to the predecessor's source) |
|
||||
| session start and environment | the login manager's X setup, `~/.xprofile`, `~/.xinitrc`, `xinitrc.d`, the D-Bus import, `environment.d` | `~/.xprofile`, `~/.xinitrc`, `~/.config/environment.d/*` |
|
||||
| display server | Xorg (`xorg-server`, `xinit`, the X apps; vendor drivers per GPU) | **no** `xorg.conf.d`; monitors by `xrandr` scripts |
|
||||
| monitor layout | `xrandr` scripts (arandr), a hotplug rule on the laptop | `~/.screenlayout/`, a scripts folder, a window-manager fragment |
|
||||
| window manager | i3 4.25 | `~/.config/i3/config` and `config.d/*`, a reload watcher (user unit) |
|
||||
| bar | i3bar with i3status-rust | `~/.config/i3status-rust/*`, 14 themes, a bar watchdog (user unit) |
|
||||
| terminal | xterm (the only terminal installed) | `~/.Xresources.d/xterm`, the window manager's binding, the compositor's opacity rule |
|
||||
| compositor | picom | `~/.config/picom/picom.conf` |
|
||||
| launcher and menus | rofi | `~/.config/rofi/*`, launcher, power-menu and theme-picker scripts |
|
||||
| notifier | dunst (D-Bus activated) | `~/.config/dunst/dunstrc`, `dunstrc.d/*` |
|
||||
| lock, idle, display power | xss-lock and i3lock-color, `xset` | `~/.xinitrc`, a lock script |
|
||||
| clipboard | greenclip, xclip | `greenclip.toml` |
|
||||
| wallpaper | feh | `~/.fehbg` (points into the predecessor's tree) |
|
||||
| theming | Adwaita dark, qt5ct/qt6ct, the desktop portal (GTK backend pinned) | GTK `settings.ini`, `qt*ct.conf`, `portals.conf`, an appearance script, `.Xresources` cursor |
|
||||
| fonts | Hack and Meslo Nerd fonts in `~/.local/share/fonts` (not packaged), noto | `~/.Xresources.d/xft` (DPI fixed at 96) |
|
||||
| keyboard | nothing set; the default layout; vendor keys via triggerhappy on the laptop | window-manager bindings, `/etc/triggerhappy` |
|
||||
|
||||
**Packages:** every piece except two is in the distribution's official repositories, and the login
|
||||
manager now is too. The two exceptions are the lock screen's colour build (`i3lock-color`) and the
|
||||
clipboard manager (`rofi-greenclip`). The Nerd fonts exist as official packages, but both machines
|
||||
carry hand-copied files instead.
|
||||
|
||||
## Identical, different, and why
|
||||
|
||||
**Byte-identical on both machines:**
|
||||
|
||||
- the session files: `.xinitrc`, `.xprofile`, `.Xresources` and its drop-ins;
|
||||
- the i3 main configuration and two of its fragments;
|
||||
- the bar's top configuration and themes;
|
||||
- picom, rofi, the GTK and Qt settings, the portal configuration, the login manager.
|
||||
|
||||
**Different, by cause:**
|
||||
|
||||
| cause | what |
|
||||
|---|---|
|
||||
| hardware | the monitor layout script; the bar's battery block; the laptop's power and vendor-key units and udev rules |
|
||||
| misassignment | the desktop carries the **laptop's** hardware fragments: the vendor-key daemon and its triggers, the backlight rule, the brightness drop-in, a touchpad reset, and the laptop's monitor layouts, in an older version |
|
||||
| drift | the notifier's position and corner radius; a "temporary" window-manager fragment from a test; the bar watchdog disabled; a second Qt configuration tool; different font builds |
|
||||
| a stale session | the desktop's session began before two fixes, so it runs two notification daemons and two portals, and its user manager lacks the desktop's identity |
|
||||
|
||||
**Dead references:** the window manager starts a polkit agent that is installed on neither machine,
|
||||
so there is no polkit agent at all. `PATH` names a directory that does not exist.
|
||||
|
||||
**Per-machine values inside shared files:**
|
||||
|
||||
- the DPI;
|
||||
- absolute home paths, in the clipboard configuration and the flatpak data directories;
|
||||
- the laptop's panel name, inside a fragment both machines carry.
|
||||
|
||||
## User units the desktop needs
|
||||
|
||||
| unit | does | laptop | desktop |
|
||||
|---|---|---|---|
|
||||
| reload watcher | reloads the window manager and bar when their files change | on | on |
|
||||
| bar watchdog | restarts a dead bar | on | off |
|
||||
| clipboard daemon | from its package | via the window manager | unit **and** window manager |
|
||||
| vendor power profile, memory guard | laptop power | on | — |
|
||||
|
||||
None is managed. Applying them as the account needs the host's user scope
|
||||
([ADR 0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)),
|
||||
which is still an open change.
|
||||
|
||||
## The predecessor's module
|
||||
|
||||
One manifest of 983 lines covers the window manager, bar, launcher, notifier, compositor, lock
|
||||
screen, session bootstrap, theming and scripts. It:
|
||||
|
||||
- has four *flavors*: i3, laptop (i3 plus the monitor wizard and hotplug), desktop (i3 plus
|
||||
nothing) and a laptop model (laptop plus vendor keys);
|
||||
- has about **105 theme variables** substituted into templates: border, gaps, fonts, workspace
|
||||
names, every colour of bar, launcher, notifier and lock screen, compositor opacity, cursor, idle
|
||||
times, Qt and GTK theme names;
|
||||
- enables the two user units from an install hook.
|
||||
|
||||
Separate modules held the login manager and the display server (one flavor, `xorg`, with a comment
|
||||
calling `wayland` "the intended sibling"). The shell module held no graphical part.
|
||||
|
||||
## Wayland and sway
|
||||
|
||||
**Nothing exists.** There is no compositor, no sway configuration, no Wayland session entry, and the
|
||||
login manager's Wayland directory is empty. What is installed is libraries:
|
||||
|
||||
- Wayland itself and the Qt Wayland plugins, which other packages pull in;
|
||||
- `xwayland`, explicitly installed and required by nothing;
|
||||
- on the desktop, an orphaned compositor library from another desktop environment, and that
|
||||
environment's portal backend, pulled in by a game launcher. The portal configuration pins
|
||||
against it.
|
||||
|
||||
Every piece a sway session needs is in the official repositories: the compositor, its lock screen,
|
||||
a terminal (`foot`), a bar (`waybar`), a notifier (`mako`) and `xwayland`.
|
||||
@@ -0,0 +1,123 @@
|
||||
# 02 — The questions and the options
|
||||
|
||||
Seven questions. Each has its options and a starting position, which is what this effort tests, not
|
||||
what it has decided.
|
||||
|
||||
## 1. How finely the desktop splits into modules
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| G1 | One desktop module, as the predecessor had | one assignment | flavors again, per machine; ADR 0174 refuses them, and the evidence shows a flavor landing on the wrong machine |
|
||||
| G2 | **One module per piece of software:** `lemurs`, `xorg`, `i3`, `i3status-rust`, `xterm`, `picom`, `rofi`, `dunst`, `xss-lock` with the lock screen, `greenclip`, `feh`, a theme module, a fonts module | each is what it declares; a machine gets exactly what is assigned; the same split already works for the shell and its plugins | about thirteen assignments per workstation |
|
||||
| G3 | G2, plus a named **set** the controller assigns as one (for example *the X desktop*) | G2's precision with G1's convenience | a set is a new controller concept |
|
||||
|
||||
**Starting position: G2.** Whether a set is worth a record is left until the thirteen assignments
|
||||
have been done by hand once.
|
||||
|
||||
## 2. The seats
|
||||
|
||||
Research 018 listed the candidates. ADR 0204 has since put the login shell in the mesh's own set,
|
||||
because a role with a protocol should not depend on one module's registration. The same reasoning
|
||||
applies here:
|
||||
|
||||
| seat | holders | protocol, first verbs |
|
||||
|---|---|---|
|
||||
| `node-login-manager` | lemurs, greetd | which sessions it offers, the default session |
|
||||
| `node-display-server` | xorg, sway | `displays`, `layout` |
|
||||
| `node-display-session` | i3, sway | `reload`, `workspaces`, `windows` |
|
||||
| `node-terminal-emulator` | xterm, foot, alacritty | which terminal `$TERMINAL` names; `open` |
|
||||
| `node-bar`, `node-compositor`, `node-launcher`, `node-notifier`, `node-lock-screen`, `node-clipboard` | the pieces above, and their Wayland counterparts | one verb or none each, until a use asks for one |
|
||||
|
||||
**A compositor that is its own server holds two seats.** Sway is both the display server and the
|
||||
display session. A module may claim several seats, so this needs nothing new.
|
||||
|
||||
**Starting position:** the first four seats are in the mesh's own set. The companion seats are
|
||||
added only as each holder is written; for those, a module without a seat is acceptable at first.
|
||||
|
||||
## 3. One module requiring another seat to be held
|
||||
|
||||
i3 needs an X server held on its node, and sway needs nothing below it. A terminal needs a session.
|
||||
To-be 37 left open how that is said.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| R1 | A seat **delivers a provision** (`x11-display`, `wayland-display`) and a module requires it at node scope. The seat table has a `delivers` field already, and requirements already resolve | existing machinery; the refusal names the seat and its possible holders, which design 27 already lists | a node-scoped requirement that never crosses machines has to be stated as such |
|
||||
| R2 | A new field, *needs the seat X held* | reads plainly | a second way to say what R1 says |
|
||||
| R3 | Nothing; assign carefully | — | the mistake the evidence shows (a laptop's fragments on a desktop) is exactly an unchecked assignment |
|
||||
|
||||
**Starting position: R1.** `xorg` and `sway` each deliver what they serve. `i3`, `picom` and `xss-lock`
|
||||
require `x11-display`. `foot` requires a Wayland display, and xterm requires an X one, which a Wayland
|
||||
session gives through `xwayland`.
|
||||
|
||||
## 4. Who starts the session, and with what environment
|
||||
|
||||
Today `~/.xinitrc` is a hand-kept second environment and the session's whole start script.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| S1 | The display server's module writes `~/.xinitrc` **into**: a mesh block at the start that sources the account's environment (`environment.sh`), merges the X resources, and runs the session's contributed start lines. The session holder's module contributes its `exec` line. The operator's lines stay after the block | one environment for shells, the session and the user manager; nothing to keep in step | the order inside `.xinitrc` becomes the slot order of a contribution (question 5) |
|
||||
| S2 | The login manager's module owns the session script under `/etc` | system scope; no home file | the environment is the account's, and the script is the same for every account |
|
||||
| S3 | Leave `.xinitrc` the operator's | nothing to build | the third environment stays |
|
||||
|
||||
**Starting position: S1.**
|
||||
|
||||
- The desktop's identity (`XDG_CURRENT_DESKTOP`) and the theme variables become **environment
|
||||
contributions** (ADR 0203) from `i3` and from the theme module. They then also reach the user
|
||||
manager through `environment.d`, which replaces most of today's allowlist import.
|
||||
- The secrets file stays out of the environment until research 027 settles how a secret reaches an
|
||||
account.
|
||||
|
||||
## 5. How other modules contribute to a holder's file
|
||||
|
||||
The terminal's settings are X resources. A bar, a launcher binding and a hardware module's key
|
||||
bindings are window-manager configuration. Autostarts are the session's. ADR 0204 built slot
|
||||
contributions for shells only.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| C1 | **The tool's own drop-in directory**, where it has one: i3's `include`, dunst's `dunstrc.d`, X resources' `#include`, XDG autostart entries, `environment.d`. Each contributor owns its own file there | no mesh change; the tools already read these directories; unassigning removes the file | each contributor names a path in another tool's directory (ADR 0204 rejected this for shells, where no drop-in convention exists); ordering is by file name |
|
||||
| C2 | **ADR 0204's mechanism generalised:** `contributes` text *for a format* (`zsh`, `xresources`, `i3`, `xinitrc`) in a slot, placed by the holder's placeholder | one mechanism, checked by the controller, order declared | every format must be named in the controller; a bigger change to ADR 0204 |
|
||||
| C3 | C1 where the tool has a drop-in convention, C2 where it does not (`.xinitrc`, `.Xresources` order) | uses each tool's own grain | two mechanisms to learn |
|
||||
|
||||
**Starting position: C3**, with the boundary drawn by the tools. A tool that reads a directory gets
|
||||
drop-ins. A file without one gets slots. This means amending ADR 0204's "shell" to "a format", which
|
||||
is a progressive extension rather than a reversal.
|
||||
|
||||
## 6. What varies per machine
|
||||
|
||||
| what | today | option |
|
||||
|---|---|---|
|
||||
| monitor layout | per-machine `xrandr` scripts, monitor names baked in | a **setting** of `xorg` (issue 168), and a `layout` verb of the display server seat |
|
||||
| DPI, fonts' size | fixed in an X resource | a setting |
|
||||
| battery block, vendor keys, brightness, touchpad | a laptop model's flavor | **a hardware module** per machine model, contributing its window-manager fragment, bar block and udev rules. The desktop simply is not assigned it |
|
||||
| theme (the 105 variables) | template substitution | settings of each tool's module, after issue 168 closes (ADR 0174). Until then each module carries today's values as its default |
|
||||
|
||||
**Starting position:**
|
||||
|
||||
- Hardware modules for what follows the machine.
|
||||
- Defaults now, settings after issue 168, for what the operator varies.
|
||||
- The monitor layout waits for settings. Until then it is an operator-owned script the display
|
||||
server's block calls if present.
|
||||
|
||||
## 7. Wayland and sway
|
||||
|
||||
Nothing of a Wayland session exists, and every piece is officially packaged. "Wayland" is a protocol,
|
||||
not a piece of software, so it has no module of its own. Its parts are `sway` (server and session),
|
||||
`swaylock`, `foot`, `waybar`, `mako`, and `xwayland` for X clients.
|
||||
|
||||
**Starting position:**
|
||||
|
||||
- The seats and the requirements (questions 2 and 3) are designed so that sway fits from the first
|
||||
day.
|
||||
- The X stack is built first, because it is what runs.
|
||||
- `sway` and its companions are written after that, and proven on one workstation as a second
|
||||
session the login manager offers beside i3. That lets the operator try it without losing the
|
||||
working desktop.
|
||||
|
||||
## Prerequisites this effort cannot remove
|
||||
|
||||
- **User-scoped units** (mesh-host #72) for the reload watcher and the bar watchdog.
|
||||
- **Settings** (issue 168) for monitors and theme values.
|
||||
- **The two packages not in the official repositories:** the lock screen's colour build and the
|
||||
clipboard manager. Each is ADR 0205's case, a pinned archive, or a choice of an official
|
||||
alternative (`i3lock` without colours; `clipmenu`/`cliphist`).
|
||||
@@ -0,0 +1,51 @@
|
||||
# 03 — What the predecessor taught
|
||||
|
||||
A study on 2026-10-04 of the retired predecessor:
|
||||
|
||||
- its 128 module manifests, their hooks, its installer and its sync engine;
|
||||
- 3,395 commits of history;
|
||||
- what it left on four machines.
|
||||
|
||||
This document holds what bears on the graphical session and on the system layer
|
||||
([research 027](../027-the-system-layer-as-modules/00-overview.md)). The evidence is in the
|
||||
predecessor's history. A commit is cited here by what it fixed, not by its hash, because the
|
||||
repository is private.
|
||||
|
||||
## Keep: what worked
|
||||
|
||||
| pattern | where it shows | in the mesh |
|
||||
|---|---|---|
|
||||
| ownership marked inside the file: inside the markers is reconciled, outside is kept verbatim | a block marker in a shared file, after an engine that rewrote whole files | kept regions (ADR 0174, ADR 0204) |
|
||||
| two writers get two files and an `include`, the include first | the ssh client's configuration, after two writers fought over one file | ADR 0203's two files; research 026 C1 |
|
||||
| one writer per file, one authority per action | only the reload watcher restarts the window manager, after three mechanisms each did | ADR 0182 |
|
||||
| refuse to write when the source of truth is unreadable; never empty a block because a query found nothing | a block of names was emptied by a failed query | — keep |
|
||||
| an unresolved template variable fails the install | a literal unfilled path was installed green | [issue 231](../../04-ISSUES/231-a-misspelled-placeholder-is-written-out-as-text/00-report.md): the mesh still has this gap |
|
||||
| prune only what you can prove you placed | stale files from earlier deliveries | ADR 0189 |
|
||||
| ensuring never rotates a credential | a silent rotation caused a retry storm, a ban of the shared address and a lost registry | ADR 0114 |
|
||||
| vendor only the files you use; never clone and link | three files instead of 77 MB | ADR 0205 |
|
||||
| copy, never symlink | a recursive delete followed a link, and every reinstall failed | ADR 0012 |
|
||||
| verification says what it did not check | a verifier said *clean* while the secret was still on disk | — keep |
|
||||
| alert once per condition | 411 alerts hid a 28-hour outage | ADR 0090 |
|
||||
|
||||
## Do not repeat
|
||||
|
||||
| failure | what it did | the mesh instead | where the mesh is still exposed |
|
||||
|---|---|---|---|
|
||||
| **Flavors** | variant files and packages per machine type: a gate dropped, the first-seen variant won, packages never installed, the verifier ignored the gate. On the day of the study a desktop carried a laptop model's fragments | one module per piece, assignment per machine (ADR 0174, research 026 §1) | a setting that switches which whole file is rendered is a flavor under another name |
|
||||
| **The freeze** | existing values outranked new defaults; templated files were rendered once (*copy if absent*) | files are generated (ADR 0011) | a created-once file (ADR 0087) is a deliberate freeze, and a push must say *kept* |
|
||||
| **Adopting drift** | a *merge* strategy made a local edit the record forever; switching strategies clobbered a person's model choice | nothing is read back (ADR 0174) | an edit outside a kept region is overwritten **silently**. The predecessor's *why is this back* loop: the push should name what it overwrote |
|
||||
| **Environment templating** | `${VAR}` matched any name; unresolved names stayed literal; comments and destination paths were interpolated | namespaced placeholders; `$` forbidden in contributed values (ADR 0203) | issue 231 |
|
||||
| **Hooks with privilege** | install hooks ran `sudo`, `chsh`, `systemctl`, `git clone` and `curl`, and swallowed failures into a warning | the `user` shape, the service shape, archives (ADR 0176, 0177, 0205) | the agent module writes under `/etc` from its own tool through `sudo` (no keep-original, no give-back); the prompt's helper downloads itself unpinned; that the operator escalates without a prompt is assumed by three modules and declared by none (research 027) |
|
||||
| **Secrets in environment files** | `.env` files left world-readable; the decryption key beside what it decrypts; a deleted secret stayed in the file, so rotation was a no-op | the vault (ADR 0113, 0114) | a predecessor file of secrets is still sourced into the graphical session on two machines (research 027 Q2) |
|
||||
| **Symlinks into a home** | a system file linked into a person's home | ADR 0012 | on the control machine, a fail2ban action file is still a predecessor link into its home tree. Deleting that tree would silently break the repeat-offender jail. The mesh's fail2ban module must own it as a file first |
|
||||
| **Green while broken** | a recorded version frozen for four months; a verifier passing what it skipped | ADR 0134, 0145, 0184 | issue 230: a plan waiting for ever reads as healthy |
|
||||
|
||||
## What it means here
|
||||
|
||||
- **Research 026:** the desktop's 88 flavor-gated files and 92 theme variables are the flavor and
|
||||
templating failures in one module. Question 1 (one module per piece) and question 6 (hardware
|
||||
modules, settings later) are the answer, and nothing in the new modules may switch whole files on a
|
||||
setting.
|
||||
- **Research 027:** the hooks that installed the AUR helper, enabled the login manager and changed
|
||||
shells are what the `package`, `service` and `user` shapes replace. Every remaining `sudo` in a module's
|
||||
own code is a debt to be named, starting with the agent module.
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# 04 — Screensaver, displays and menus
|
||||
|
||||
Three areas the operator named on 2026-10-04, as their own modules. Each sharpens a row of
|
||||
[01](01-what-the-workstations-run.md) and a question of [02](02-the-questions-and-the-options.md).
|
||||
|
||||
## The screensaver: idle, lock and display power
|
||||
|
||||
**Measured on both workstations:**
|
||||
|
||||
- **Idle and lock** are three things wired by hand in the session's start script:
|
||||
- the X screensaver timeout (`xset s 1800`);
|
||||
- the display power timeouts (`xset dpms`);
|
||||
- `xss-lock` running the colour build of `i3lock` through a wrapper, in a respawn loop.
|
||||
- **A second screensaver,** xscreensaver, is installed and deliberately not started. Earlier it
|
||||
overrode the display power settings with its own, and locked nothing. Its configuration file is
|
||||
still in the home.
|
||||
- **The lock screen's 20-odd colours and formats** were predecessor theme variables.
|
||||
- **The colour build is not in the official repositories** (research 026/01).
|
||||
|
||||
**Starting position:**
|
||||
|
||||
- **One module for the lock screen,** holding `node-lock-screen`: the locker and its wrapper as the
|
||||
module's own files, the screensaver and display power timeouts, and `xss-lock`.
|
||||
- The timeouts and colours are its defaults, and settings later (issue 168).
|
||||
- The colour build ships as ADR 0205's pinned archive, or the module uses the official `i3lock`.
|
||||
That is the operator's choice, and the colours are the only difference.
|
||||
- xscreensaver is not a module; its package and file are removed.
|
||||
- `xss-lock` needs the logind session, so it stays a session-start line contributed into
|
||||
`.xinitrc`'s block (question 4), not a unit.
|
||||
|
||||
## Monitor layout (xrandr)
|
||||
|
||||
**Measured:**
|
||||
|
||||
- Each workstation has a layout script generated by `arandr`, with the monitor names baked in. One
|
||||
workstation also has several layouts for named places, a hotplug rule and a wizard.
|
||||
- **The desktop carried the laptop's layout scripts.**
|
||||
- No `xorg.conf.d`, and no layout tool beyond the scripts.
|
||||
|
||||
**Starting position: `autorandr`** (official repositories) inside the display server's module.
|
||||
|
||||
- `autorandr` saves a layout as a profile **keyed by the connected monitors' identities** (their EDID)
|
||||
and applies the matching one at login and on hotplug.
|
||||
- Profiles therefore need no machine's name. A profile can be shared mesh-wide and simply never
|
||||
matches on a machine without those monitors. That is exactly the "say it by what is there, never by
|
||||
a name" rule (ADR 0112).
|
||||
- The profiles are the operator's data, saved by the tool itself, so they are *found* (ADR 0182). A
|
||||
`layout` verb on `node-display-server` lists, saves and applies them.
|
||||
- The arandr scripts and the hotplug rule retire once a profile exists for each.
|
||||
|
||||
## Menus: rofi and dmenu
|
||||
|
||||
**Measured:**
|
||||
|
||||
- rofi is the launcher, the power menu, the theme picker and the clipboard menu.
|
||||
- The operator's scripts call `rofi -dmenu` in four places and **plain `dmenu` in two. dmenu is
|
||||
installed on neither workstation, so those two fail.**
|
||||
|
||||
**Starting position:**
|
||||
|
||||
- **`rofi` holds `node-launcher`**, and the seat's protocol includes a **dmenu-compatible command**:
|
||||
read choices on standard input, print the chosen one. Scripts call that command, not a program by
|
||||
name.
|
||||
- **`dmenu` is a module of its own** (official repositories), able to hold the same seat on a machine
|
||||
that wants it, for instance a Wayland session where `wofi` or `fuzzel` would hold it instead.
|
||||
- The rofi module carries its theme files, and the menus that belong to other modules arrive as those
|
||||
modules' scripts:
|
||||
- power menu → the session;
|
||||
- clipboard menu → the clipboard module;
|
||||
- theme picker → settings, once issue 168 closes.
|
||||
|
||||
## The clipboard: xclip and greenclip
|
||||
|
||||
**Measured:**
|
||||
|
||||
- **greenclip** keeps the clipboard's history, and rofi shows it on a key binding.
|
||||
- **greenclip is not in the official repositories.**
|
||||
- It is started two ways: the window manager's configuration starts it on both workstations, and on
|
||||
one a user unit is enabled as well.
|
||||
- Its configuration names an absolute home path.
|
||||
- **xclip** (official) is the command-line clipboard the operator's scripts use.
|
||||
|
||||
**Starting position:**
|
||||
|
||||
- **`xclip` is a module of its own,** a package and nothing else. It is the tool scripts depend on,
|
||||
and a module that needs it requires it.
|
||||
- **The clipboard manager holds `node-clipboard`:** its daemon, started once by the session (a session
|
||||
contribution, or a user unit once user-scoped units ship, never both), its configuration with no
|
||||
absolute path, and its menu binding contributed to the window manager.
|
||||
- **Which manager holds it is the operator's choice:**
|
||||
- greenclip, as today, shipped under ADR 0205;
|
||||
- or `clipmenu` (official), which feeds the same dmenu-compatible command as the launcher seat above,
|
||||
and needs no archive.
|
||||
- **On Wayland** the same seat is held by `cliphist` with `wl-clipboard`, both official.
|
||||
|
||||
## Fonts
|
||||
|
||||
**Measured:**
|
||||
|
||||
- The fonts the desktop uses are **hand-copied files** in the account's font directory, not packages:
|
||||
- a Nerd font for the window manager, the bar and the terminal;
|
||||
- a second one for the prompt;
|
||||
- on one workstation, the same four files twice, once under URL-encoded names;
|
||||
- on the other, a different build of the same font and three more copied from a theme's repository.
|
||||
- The system's default monospace is a different font (`Noto Sans Mono`), so anything that asks for
|
||||
`monospace` gets another face than the terminal.
|
||||
- The DPI is fixed in an X resource.
|
||||
- **Every Nerd font in use is in the official repositories** (Hack, Meslo, Iosevka, JetBrains Mono).
|
||||
|
||||
**Decided** (the operator left the choice open, except that it must not be today's Hack):
|
||||
|
||||
| role | face | why |
|
||||
|---|---|---|
|
||||
| monospace: terminal, window manager, bar, launcher, prompt | **JetBrains Mono Nerd Font** | built for long reading in a terminal, unambiguous `0O1lI`, optional ligatures; a version-3 Nerd font, so every icon the prompt and bar use is present |
|
||||
| interface: GTK, Qt, notifications | **Inter** | designed for screens, clear at small sizes |
|
||||
| icons missing from any face | Nerd Fonts Symbols | a fallback, so a font without icons still shows them |
|
||||
| emoji | Noto Color Emoji | |
|
||||
| serif and every other script | Noto | |
|
||||
|
||||
All five are official packages.
|
||||
|
||||
**Starting position:**
|
||||
|
||||
- **A `fonts` module:** those packages, and a fontconfig file it owns that maps `monospace`, `sans-serif`,
|
||||
`serif` and the emoji and symbol fallbacks to the chosen faces, so every program agrees.
|
||||
- The terminal, bar, launcher and prompt modules name the family, not a file.
|
||||
- The DPI becomes the display server's setting (issue 168).
|
||||
- The copied files are removed by the operator once the packages are in (ADR 0182).
|
||||
- Fonts are not a seat: several coexist. The module owns the one place where *the* default is said.
|
||||
@@ -0,0 +1,77 @@
|
||||
# 05 — The tools each module serves
|
||||
|
||||
A first catalogue for the modules of research 026 and 027, as the operator asked: "all kinds of useful
|
||||
tools for all these modules". Each tool is served by the node's runtime (ADR 0175), on the machine the
|
||||
module runs on. Through discovery (ADR 0195) it is reachable from any machine as
|
||||
`<machine>/<module>.<tool>`, or as `<machine>/<seat>.<verb>` where a seat defines it.
|
||||
|
||||
**Conventions:**
|
||||
|
||||
- **(r)** reads.
|
||||
- **(a)** acts on the machine, escalating where it must, as the packet filter does (to-be 38 WP4).
|
||||
- **(d)** is a desktop act that needs the operator's session.
|
||||
- A tool that changes something a module declares says so in its answer: the next push restores the
|
||||
declaration.
|
||||
- Every tool answers structured data, not prose (issue 229).
|
||||
- **Seat verbs** (marked *seat*) are the protocol every holder of that seat serves. The rest are the
|
||||
module's own.
|
||||
|
||||
## The graphical session (026)
|
||||
|
||||
| module | tools |
|
||||
|---|---|
|
||||
| `xorg` (*node-display-server*) | *seat* `displays` (r: outputs, modes, rates, connected monitors with their identity) · *seat* `layout` (r/a: list, save, apply an autorandr profile) · `set-mode` (a: one output's resolution, rate, rotation, scale) · `primary` (a) · `dpi` (r/a) · `input-devices` (r) · `input-set` (a: touchpad tap, natural scroll, pointer speed) · `keyboard` (r/a: layout and options) · `screenshot` (d: one screen or all, as a file) · `x-log` (r: the server's errors since start) |
|
||||
| `i3` (*node-display-session*) | *seat* `reload` (a) · *seat* `workspaces` (r) · *seat* `windows` (r: tree with classes, titles, workspaces) · `focus` (d: window or workspace) · `move` (d: window to workspace or output) · `layout-save` / `layout-restore` (d: a workspace's arrangement) · `exec` (d: start a program in the session) · `kill` (d) · `bindings` (r: every key binding and what it runs) · `config-check` (r: validate the composed configuration before a reload) · `marks` (r) · `scratchpad` (d) |
|
||||
| `sway` (*node-display-server*, *node-display-session*) | the same seat verbs over Wayland, plus `outputs` (r) and `idle-inhibitors` (r) |
|
||||
| `lemurs` (*node-login-manager*) | *seat* `sessions` (r: what the login screen offers) · *seat* `default-session` (r/a) · `logins` (r: who logged in when, from the journal) |
|
||||
| `xterm` (*node-terminal-emulator*) | *seat* `open` (d: a terminal, optionally running a command, in a directory) · `font` (r/a: face and size) · `colours` (r) |
|
||||
| `i3status-rust` (*node-bar*) | *seat* `reload` (a) · `blocks` (r: what the bar shows and each block's current value) · `block-run` (r: run one block once and answer its output) · `themes` (r) |
|
||||
| `picom` (*node-compositor*) | *seat* `restart` (a) · `rules` (r: opacity, shadow and blur rules in force) · `window-opacity` (d) · `toggle` (d: compositing off and on, for a game or a test) |
|
||||
| `rofi` (*node-launcher*) | *seat* `menu` (d: show a list, answer the chosen line: the dmenu-compatible command as a tool) · `applications` (r: the desktop entries it would offer) · `themes` (r) · `run` (d) |
|
||||
| `dmenu` (*node-launcher*) | *seat* `menu` (d) |
|
||||
| `dunst` (*node-notifier*) | *seat* `send` (d: title, body, urgency, actions) · *seat* `history` (r) · `pause` / `resume` (d: do not disturb) · `close-all` (d) · `rules` (r) · `count` (r: shown, waiting, history) |
|
||||
| lock module (*node-lock-screen*) | *seat* `lock` (d) · `idle` (r/a: screensaver and display power timeouts) · `inhibit` (d: keep the screen on for a while) · `locked` (r: is the session locked now, and since when) |
|
||||
| clipboard manager (*node-clipboard*) | *seat* `history` (r: entries, newest first, length-limited) · *seat* `copy` (d: put text on the clipboard) · `paste` (r: what the clipboard holds now) · `clear` (d) · `delete` (d: one entry) |
|
||||
| `xclip` | `copy` (d) · `paste` (r): the plain clipboard without a manager |
|
||||
| `feh` (wallpaper) | `set` (d: an image, per output) · `current` (r) |
|
||||
| `fonts` | `families` (r: installed faces) · `match` (r: what `monospace`, `sans-serif` and `emoji` resolve to) · `glyph` (r: which installed font has a given character) · `cache-rebuild` (a) |
|
||||
| theme module | `appearance` (r/a: dark or light, for GTK, Qt and the portal at once) · `cursor` (r/a) · `icons` (r) · `portal-check` (r: which portal backend answers which interface) |
|
||||
| `gnome-keyring` (*node-secret-service*) | *seat* `unlocked` (r) · `lock` (d) · `collections` (r: names and item counts, never secrets) · `ssh-keys` (r: what the agent holds, by fingerprint) |
|
||||
| desktop hardware module (laptop) | `brightness` (r/a: panel and keyboard) · `battery` (r: charge, health, cycles, limit) · `charge-limit` (r/a) · `gpu-mode` (r/a: integrated, hybrid, discrete) · *seat* `profile` (r/a: quiet, balanced, performance) · `thermals` (r: temperatures and fan speeds) · `power-draw` (r) |
|
||||
|
||||
## The system and the account (027)
|
||||
|
||||
| module | tools |
|
||||
|---|---|
|
||||
| `docker` (*node-container-runtime*, ADR 0166) | *seat* `list`, `inspect`, `logs`, `stats`, `start`, `stop`, `restart` (r/a) · `images` (r: with size and which container uses each) · `prune` (a: dangling images, stopped containers not held by the mesh, build cache, with a dry run first) · `disk-usage` (r) · `networks` (r) · `volumes` (r: with what mounts each and whether the mesh holds it) · `events` (r: the last hour) · `daemon-config` (r) |
|
||||
| `docker-compose` | `projects` (r: compose projects running and where their files are) · `up` / `down` / `restart` (a: one project, by directory) · `logs` (r) · `ps` (r) |
|
||||
| `sudo` | `rules` (r: what the account may run, without a prompt and with one) · `check` (r: does the escalation the mesh relies on work here) |
|
||||
| `pacman` | `search` (r) · `installed` (r: with version and explicitly or as a dependency) · `info` (r) · `owns` (r: which package owns a path) · `files` (r) · `updates` (r: what an upgrade would change) · `upgrade` (a: with the news first) · `orphans` (r) · `remove-orphans` (a) · `cache` (r/a: size, clean to the last N versions) · `history` (r: installs and upgrades from the log) · `mirrors` (r/a: rank and refresh) · `news` (r: distribution news since the last upgrade) |
|
||||
| AUR (package repository, 027 question 1) | `search` (r) · `build` (a: on the build machine, into the mesh's repository) · `outdated` (r) · `published` (r) |
|
||||
| `snapd`, `flatpak` | `list` (r) · `install` / `remove` (a) · `update` (a) · `runtimes` (r) · `disk-usage` (r) |
|
||||
| `time-sync` | `status` (r: synchronised, offset, server) · `servers` (r) · `sync-now` (a) |
|
||||
| `localization` | `get` (r: locale, time zone, keymap) · `time-zone` (r/a) · `locales` (r) |
|
||||
| `kernel` | `running` (r: version, command line, uptime) · `installed` (r) · `modules` (r: loaded, with what uses them) · `reboot-needed` (r: a newer kernel or library than the one running) · `microcode` (r) · `boot-entries` (r) · `initramfs-rebuild` (a) · `dmesg` (r: errors since boot) |
|
||||
| `logrotate` | `status` (r: last rotation per log) · `force` (a: one configuration) · `big-logs` (r: the largest logs on the machine) |
|
||||
| `avahi` | `browse` (r: services on the local network) · `resolve` (r) |
|
||||
| `cups` | `printers` (r) · `queue` (r) · `cancel` (a) · `print` (a: a file to a printer) · `default` (r/a) |
|
||||
| `bluetooth` | `devices` (r: paired, connected, battery where reported) · `connect` / `disconnect` (a) · `scan` (r) · `power` (r/a) |
|
||||
| `ssh-client` (owns `~/.ssh`) | `hosts` (r: every `Host` and where it came from: the mesh, a module, the operator) · `check` (r: modes, keys without a passphrase, keys unused for a year, stale `known_hosts` entries) · `authorized` (r: who may log in, by fingerprint and comment) · `revoke` (a: one authorized key, into the operator's region) · `known-host` (r/a: verify, refresh one host's key) · `test` (r: can this machine reach a host and authenticate, batch mode) |
|
||||
| `sshd` | `sessions` (r: who is logged in, from where) · `config-effective` (r: `sshd -T`) · `failed-logins` (r: since a time, with fail2ban's verdicts) |
|
||||
| scripts modules | `list` (r: each script with its one-line description) · `run` (a: one script by name with arguments, as the account, bounded like `execute`) · `which` (r: which module ships a command) |
|
||||
| `node-env` (*node-environment*) | `show` (r: every variable and `PATH` entry with the module that contributed it) · `diff` (r: what a shell actually has versus what the mesh composed) |
|
||||
| `zsh` (*node-login-shell*) | *seat* `execute` · `zsh_config` (r) · `history-search` (r: the account's history, by pattern) · `functions` (r: aliases and functions in force, with where each came from) · `startup-time` (r: how long an interactive shell takes to start, per slot) |
|
||||
| `memory-pressure` | `status` (r: memory, swap, compressed swap ratio, pressure stall) · `top` (r: the largest processes) · `oom-history` (r: what was killed, when) |
|
||||
| `zfs` | `pools` (r: health, capacity, fragmentation) · `datasets` (r) · `snapshots` (r/a: list, create, destroy by name) · `scrub` (r/a: status, start) · `errors` (r) · `arc` (r: cache statistics) |
|
||||
| `nfs-server`, `samba` | `exports` / `shares` (r) · `clients` (r: who has it mounted now) · `reload` (a) |
|
||||
| `nfs-client`, `smb-client` | `mounts` (r: each share, mounted or not, and since when) · `mount` / `unmount` (a) · `test` (r: is the server reachable, is the export offered) |
|
||||
| hosts-file holder (*node-hosts-file*, ADR 0199) | *seat* `entries`, `add`, `remove` |
|
||||
| `vnstat`, `lm_sensors` | `traffic` (r: per interface, day, month) · `sensors` (r) |
|
||||
| mail consumer (future effort) | `accounts` (r) · `search` (r) · `unread` (r) · `read` (r: one message) · `mark` (a) · `send` (a) |
|
||||
|
||||
## What this catalogue is for
|
||||
|
||||
It is a starting list, not a contract. A tool becomes a contract only when it is a seat's verb, and
|
||||
each seat's verbs are decided in that seat's record (ADR 0132). A module's own tools can grow freely.
|
||||
Every row above is a tool the operator would otherwise run by hand over ssh. That is the measure of
|
||||
whether one is worth writing.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
status: active
|
||||
initiated: 2026-10-04
|
||||
touches:
|
||||
- 02-DECISIONS/0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md
|
||||
- 02-DECISIONS/0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md
|
||||
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
|
||||
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
|
||||
- 03-DESIGN/01-to-be/37-the-operators-machine.md
|
||||
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
|
||||
became: []
|
||||
---
|
||||
|
||||
# 027 — The system layer as modules
|
||||
|
||||
## What is investigated
|
||||
|
||||
What runs on the machines below the operator's home and outside the mesh's own services, and which
|
||||
of it should be modules. That covers:
|
||||
|
||||
- the container runtime and its tools;
|
||||
- privilege (sudo);
|
||||
- the package manager and the software it cannot install;
|
||||
- time, locale, the kernel and boot;
|
||||
- log rotation;
|
||||
- the machine-specific daemons the workstations and servers carry: printing, bluetooth, VPN
|
||||
clients, virtualisation, storage, sharing.
|
||||
|
||||
## Why
|
||||
|
||||
The operator asked for the system level beside the graphical session. In particular:
|
||||
|
||||
- a `docker` module (decided in principle by the proposed ADRs 0165 and 0166, never built);
|
||||
- a `docker-compose` module for development work, assigned **only to the two workstations**.
|
||||
|
||||
Measured in [01](01-what-the-machines-run.md): on four machines, almost nothing at this level is
|
||||
owned by a module. The pieces differ by machine for no recorded reason. Three findings are security
|
||||
matters on their own.
|
||||
|
||||
## How it is approached
|
||||
|
||||
**Adopting is also improving** (the operator, 2026-10-04). A module is not a copy of what a machine
|
||||
does today. Making it is the moment to fix what is broken, drop what is dead, choose the better tool
|
||||
and remove the leftovers. Every module's design lists its improvements over today. **Every module
|
||||
also serves tools,** many of them, for reading, acting and diagnosing; a module that only places a
|
||||
package and a file is unfinished. The tools are catalogued in
|
||||
[026/05](../026-the-graphical-session-as-modules/05-the-tools-each-module-serves.md).
|
||||
|
||||
## What it touches
|
||||
|
||||
- **The container runtime seat** (ADRs 0165 and 0166, both proposed).
|
||||
- **The host's `package` shape**, which installs from the distribution's official repositories only,
|
||||
while the workstations carry 67 and 114 packages from elsewhere.
|
||||
- **How a secret reaches the account's environment.** ADR 0203 forbids it in the contributed
|
||||
environment, but a predecessor file supplies such secrets today.
|
||||
- **The facts the mesh assumes and never declares,** above all that the operator account escalates
|
||||
without a prompt.
|
||||
|
||||
## Documents
|
||||
|
||||
- [01 — What the machines run](01-what-the-machines-run.md): evidence.
|
||||
- [02 — Candidates and questions](02-candidates-and-questions.md)
|
||||
- [03 — The account's own tools](03-the-accounts-own-tools.md): `~/.ssh` as one module's, scripts on every machine, the keyring, the laptop's power management, mail as events
|
||||
- The predecessor's lessons, shared with research 026: [026/03](../026-the-graphical-session-as-modules/03-what-the-predecessor-taught.md)
|
||||
@@ -0,0 +1,103 @@
|
||||
# 01 — What the machines run
|
||||
|
||||
Measured 2026-10-04 on four machines, read-only, including the host's own record of what it applied:
|
||||
two servers (the anchor and a home server) and two workstations (a laptop and a desktop). "Owned"
|
||||
means a module the mesh assigns declares it.
|
||||
|
||||
## The container runtime
|
||||
|
||||
| | anchor | home server | laptop | desktop |
|
||||
|---|---|---|---|---|
|
||||
| docker | 29.8.2 | 29.8.2 | 29.7.2 | 29.7.2 |
|
||||
| compose | 5.5.1 | 5.6.0 | 5.5.0 | 5.5.0 |
|
||||
| buildx | 0.37.2 | — | — | — |
|
||||
| podman | — | 6.1.3 | 6.1.0 | 6.1.0 |
|
||||
| `docker.socket` | disabled | enabled | enabled | enabled |
|
||||
| `containerd.service` | disabled | disabled | disabled | **enabled** |
|
||||
| `daemon.json` beyond the shared keys | direct routing, two more insecure registries | log rotation (100 MB × 10) | — | — |
|
||||
| docker group | operator, **a CI user** | operator | operator | operator |
|
||||
|
||||
**Ownership:**
|
||||
|
||||
- The `docker` package is owned on one machine only, by the installer's bootstrap, not by a module.
|
||||
- `docker.service` is declared indirectly, by the name resolver and the private-network modules,
|
||||
which each merge their own keys into `daemon.json`.
|
||||
- Nothing owns the socket, containerd, compose, buildx or the group.
|
||||
|
||||
**Compose in use:**
|
||||
|
||||
- On the servers, no running container belongs to a compose project. Their compose files are
|
||||
pre-mesh trees under the operator's and root's homes, plus a dangling enabled unit for one of them.
|
||||
- On the workstations, compose runs development stacks, and pre-mesh service trees sit under a
|
||||
top-level directory.
|
||||
|
||||
The mesh marks its own containers with a host label. On the workstations, a handful of unlabelled
|
||||
development and test containers run beside its build agent.
|
||||
|
||||
## Privilege
|
||||
|
||||
- The operator account escalates **without a prompt on all four machines**. The mesh relies on this,
|
||||
but it is set by hand in `/etc/sudoers` (a `wheel` rule on two machines, the account named on
|
||||
two), and nothing declares it.
|
||||
- On the anchor, a **CI user from the predecessor** keeps passwordless sudo and docker membership,
|
||||
and a predecessor drop-in in `sudoers.d` survives.
|
||||
- On the desktop, the operator account is also in the **`root` group**.
|
||||
|
||||
## The package manager
|
||||
|
||||
- `pacman.conf` is stock except on one server (parallel downloads).
|
||||
- The mirror list was generated once by a tool that is no longer installed. On the anchor, it is the
|
||||
hosting provider's single mirror.
|
||||
- An AUR helper is installed everywhere.
|
||||
- **Packages from outside the official repositories:** 2 on the anchor, 21 on the home server,
|
||||
67 on the laptop, 114 on the desktop. They include:
|
||||
- the agent CLI, which a catalogue module declares as a package and the host cannot install;
|
||||
- a VPN client;
|
||||
- a remote-access client;
|
||||
- printer drivers;
|
||||
- GPU tools;
|
||||
- a kernel module built from source (DKMS) for a storage filesystem;
|
||||
- a snap daemon.
|
||||
|
||||
## Time, locale, kernel, boot
|
||||
|
||||
| | anchor | home server | laptop | desktop |
|
||||
|---|---|---|---|---|
|
||||
| time zone, keymap | **another zone**, a non-US console keymap | local zone, unset | local zone, unset | local zone, unset |
|
||||
| time sync | timesyncd plus a provider drop-in | timesyncd | timesyncd | **ntpd**, timesyncd disabled |
|
||||
| bootloader | grub (BIOS) | systemd-boot **and** grub | systemd-boot | systemd-boot **and** grub |
|
||||
| kernels | one | two, plus a DKMS filesystem module | one | one, plus a DKMS controller driver |
|
||||
| microcode | **none** | yes | yes | **none** |
|
||||
| swap | RAID partition | partition | zram, a file and a partition | partition |
|
||||
| log rotation timer | not found | enabled | not found | not found |
|
||||
|
||||
## Daemons and services no module owns
|
||||
|
||||
- **All four:** avahi.
|
||||
- **Workstations:**
|
||||
- a VPN client daemon (both);
|
||||
- virtualisation (incus) with a hand-made unit that inserts container-runtime firewall rules (both);
|
||||
- printing and bluetooth;
|
||||
- GPU and power tuning per model;
|
||||
- a remote-access daemon (laptop);
|
||||
- snap and flatpak (desktop);
|
||||
- the local model server, run from a hand-written unit although a catalogue module for it exists
|
||||
(desktop);
|
||||
- Samba sharing and a network filesystem mount from the home server (desktop). A second mount is
|
||||
failing, and its **credential is written in clear in `/etc/fstab`**.
|
||||
- **Servers:**
|
||||
- a storage pool (about 167 TB) with its import, mount and scrub units, an NFS server and Samba
|
||||
sharing (home server);
|
||||
- traffic and sensor monitoring (home server);
|
||||
- a DHCP client daemon the catalogue has a module for but does not assign there (home server);
|
||||
- cron, an entropy daemon, and the **legacy `iptables` services**, which run beside the mesh's own
|
||||
filter (anchor).
|
||||
- **Not found anywhere:** a backup agent, a monitoring agent, a second VPN mesh.
|
||||
|
||||
## What is plain debris
|
||||
|
||||
- Dangling enabled-unit links on three machines.
|
||||
- Predecessor blocks in `/etc/hosts` on both servers.
|
||||
- The CI user, and the predecessor sudoers drop-in, on the anchor.
|
||||
- Pre-mesh compose trees on the anchor, the home server and the desktop.
|
||||
- Unlabelled test containers on the workstations.
|
||||
@@ -0,0 +1,128 @@
|
||||
# 02 — Candidates and questions
|
||||
|
||||
## Decided by the operator on 2026-10-04
|
||||
|
||||
- **`docker`** holds the container runtime seat on every machine, as ADRs 0165 and 0166 propose. Those
|
||||
records are promoted from proposed when it is built.
|
||||
- **`docker-compose` is a module of its own,** the distribution's package and nothing else. It is
|
||||
assigned **only to the two workstations**, for development work. The servers run nothing through
|
||||
compose.
|
||||
|
||||
Later the same day, on the candidates below:
|
||||
|
||||
- **Yes, all of them:** `docker`, `docker-compose`, `sudo`, `pacman`, an AUR helper (question 1),
|
||||
`time-sync`, `kernel` (with boot and microcode), `logrotate`, `avahi`, `cups` with the printer's
|
||||
driver, and every server-only candidate.
|
||||
- **Locale, time zone and keymap are one module, `localization`.**
|
||||
- **`snapd` and `flatpak`** are modules, on the two workstations only.
|
||||
- **`incus` is the lab's,** whose module depends on it. It is not a module of its own beside the lab.
|
||||
- **The agent's and the local model server's modules are still being developed,** and are not
|
||||
assigned until they are.
|
||||
- **The predecessor's CI user is retired.** It was removed from the anchor the same day, with its
|
||||
sudoers line, its docker membership and a dangling unit link; the backup is on the machine.
|
||||
|
||||
## Candidate modules
|
||||
|
||||
**On every machine:**
|
||||
|
||||
| module | owns | first reason |
|
||||
|---|---|---|
|
||||
| `docker` | the packages (runtime, containerd), the service and socket, `daemon.json`'s base keys (live restore, log rotation), the docker group's members | four machines, four configurations, one owner on one |
|
||||
| `sudo` | the operator account's escalation as a drop-in, declared | the mesh's tools rely on it (to-be 38 WP4) and nothing states it |
|
||||
| `pacman` | `pacman.conf`'s few keys, the mirror list and its refresher, cache cleaning | mirrors generated once and never again |
|
||||
| `time-sync` | timesyncd and its drop-ins | two daemons across four machines |
|
||||
| `localization` | locale, time zone, console keymap (one module, the operator's choice) | one machine differs, with no record why |
|
||||
| `kernel` | the kernel packages, microcode, initramfs presets | two machines without microcode |
|
||||
| `logrotate` | the timer and the base configuration | rotation runs on one machine of four |
|
||||
| `avahi` | the daemon and name-service switch entry | on all four, owned by none |
|
||||
|
||||
**On the workstations only:**
|
||||
|
||||
- `docker-compose`;
|
||||
- `lemurs`, the login manager (research 026);
|
||||
- a VPN client module;
|
||||
- `incus` with its forward unit (the lab module declares the package on one workstation only);
|
||||
- `cups` with the printer's driver;
|
||||
- `bluetooth`;
|
||||
- per-model **hardware** modules: GPU, power, vendor keys, brightness. These are the same modules
|
||||
research 026 needs for the desktop's fragments.
|
||||
|
||||
**On the servers only:**
|
||||
|
||||
- `zfs` with its scrub timer, and the long-term kernel it builds against;
|
||||
- `nfs-server`;
|
||||
- `samba`;
|
||||
- `vnstat`, `lm_sensors`.
|
||||
|
||||
`cron` on the anchor serves one stock file and can go. So can the entropy daemon on a modern
|
||||
kernel.
|
||||
|
||||
**Retire, not model:** the legacy `iptables` services on the anchor. They duplicate the mesh's
|
||||
filter, which is ADR 0100's ground.
|
||||
|
||||
## Questions this effort has to answer
|
||||
|
||||
1. **Software outside the official repositories.** The host's `package` shape installs from the
|
||||
official repositories only. A catalogue module already declares an AUR package (the agent CLI),
|
||||
which no machine could install, and the workstations carry 181 such packages between them.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| P1 | ADR 0205's pinned vendored archive, per piece | exists | wrong for packages that build native code or kernel modules |
|
||||
| P2 | **The build machine builds AUR packages into a package repository the mesh serves** from its artifact store. The host then installs them as packages, signed | one shape for every package; pinned, reviewed and built once | a repository to serve and a signing key to keep |
|
||||
| P3 | An AUR helper on each machine, driven by the host | nothing to serve | builds on every machine, unpinned: the predecessor's `git clone` in another form |
|
||||
|
||||
Starting position: P2, for anything with native code. ADR 0205 stays for plain files such as a
|
||||
theme.
|
||||
|
||||
2. **Secrets in the account's environment.** A predecessor file feeds package-registry and API tokens
|
||||
to the session. ADR 0203 refuses secrets in contributed values, because they travel in the clear.
|
||||
The candidate is ADR 0182's third class: a module's own process writes a mode-0600 file of
|
||||
exports, from secrets the vault hands it over the bus, and the shell and the session source it.
|
||||
This needs its own record.
|
||||
|
||||
3. **Per-machine sizing and drivers.** The swap layout, the GPU, the storage pool and the boot
|
||||
loader are facts of one machine's hardware. They belong in hardware modules, or in settings
|
||||
(issue 168), not in the shared ones.
|
||||
|
||||
4. **What a module may leave behind.** Compose is installed on both servers, unowned. The mesh
|
||||
removes nothing it did not make. The choice is between an operator's one-off removal and a
|
||||
server-side `absent` declaration.
|
||||
|
||||
5. **The hosts file.** ADR 0199 (decided on an open change, not yet merged)
|
||||
gives `/etc/hosts` to one module through a seat, `node-hosts-file`, with an operator region and
|
||||
three verbs. It is not built. Today the private network's foundation writes only its own block, and
|
||||
the rest of each file is a predecessor's stale blocks (both servers) or the operator's development
|
||||
names (both workstations). The candidate module is that seat's first holder. It takes the
|
||||
private-network block as a contribution, and its operator region replaces the hand-kept lines.
|
||||
|
||||
6. **Mounts.** The host has no shape for a filesystem mount; ADR 0091 is about what a container
|
||||
mounts. One workstation mounts a share of the home server over NFS, and a second share over SMB.
|
||||
That second one fails, and its credential sits in clear in `/etc/fstab`.
|
||||
|
||||
| | option | for | against |
|
||||
|---|---|---|---|
|
||||
| M1 | **A module owns `/etc/fstab`** and other modules contribute lines | one file, as people know it | the file also carries the root and boot filesystems the installer wrote, which no module should rewrite; a slot contribution into a file that can stop a machine booting |
|
||||
| M2 | **Each client module writes its own systemd mount (and automount) unit**, which is the service manager's drop-in for exactly this. The `nfs-client` or `smb-client` module declares the unit file and the service shape enables it. `/etc/fstab` stays the machine's | no new host shape, and no shared file; the unit names its own dependencies (network online, the private network) and an automount does not hang a boot when the server is away; unassigning removes the mount | a mount reads as a unit, not a line |
|
||||
| M3 | A new `mount` shape in the host | the host knows what a mount is | a second way to say what M2 says |
|
||||
|
||||
Starting position: **M2.** The credential an SMB mount needs is a secret, written by the module's
|
||||
own process from the vault, mode 0600, which is question 2's mechanism. The pair is a server
|
||||
module exporting (`nfs-server`, `samba`) and a client module mounting. The client requires the
|
||||
share the server provides, so the mount is resolved, not hand-typed.
|
||||
|
||||
7. **Two DHCP clients on one interface.** The home server runs `dhcpcd`, a DHCP *client* (no machine
|
||||
runs a DHCP server), next to the network manager, which is its assigned networking module. Both
|
||||
lease an address on the same interface, which therefore carries two LAN addresses. The catalogue's
|
||||
`dhcpcd` module is assigned nowhere, and this unit is a leftover. The network manager is the
|
||||
machine's one DHCP client, and `dhcpcd` should be disabled there.
|
||||
|
||||
## Security findings, independent of any module
|
||||
|
||||
1. A filesystem credential in clear text in a workstation's `/etc/fstab`, for a mount that is failing
|
||||
anyway.
|
||||
2. A predecessor CI user with passwordless sudo and docker membership on the anchor, and a
|
||||
predecessor sudoers drop-in. *The user was removed on 2026-10-04; the drop-in remains.*
|
||||
3. The operator account in the `root` group on one workstation.
|
||||
|
||||
Each is one small change. None waits for a module.
|
||||
@@ -0,0 +1,169 @@
|
||||
# 03 — The account's own tools: ssh, scripts, mail
|
||||
|
||||
Three further directions from the operator on 2026-10-04. Each is account-level, like the shell
|
||||
([to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)).
|
||||
|
||||
## `~/.ssh` is one module's
|
||||
|
||||
*"A module owns `~/.ssh`, so it is its responsibility that every folder is set up consistently and
|
||||
correctly."*
|
||||
|
||||
**Measured:**
|
||||
|
||||
- The catalogue's `ssh-client` module owns the directory (mode 0700) and one region of
|
||||
`~/.ssh/config`: a `Host` block per machine of the mesh. It owns nothing else.
|
||||
- On one workstation, a predecessor's header, `Include` and hand-written host block sat **above** the
|
||||
mesh's region. ssh takes the first match, so the predecessor's entries were the ones in force, for
|
||||
the same machines. Removed on 2026-10-04.
|
||||
- On the control machine, two keys of a retired CI system were still in the operator's
|
||||
`authorized_keys`, able to log in as the operator. Removed the same day.
|
||||
- Permissions differ by file and by machine. Backups of the configuration lie beside it.
|
||||
|
||||
**Starting position:** `ssh-client` becomes the holder of everything under `~/.ssh`, classified as
|
||||
ADR 0182 asks:
|
||||
|
||||
| path | class | how |
|
||||
|---|---|---|
|
||||
| `~/.ssh/`, its mode, every file's mode | owned | the directory resource, plus a check verb that reports a file with the wrong mode |
|
||||
| `~/.ssh/config` | written into, the mesh's block **at the start** | the mesh's hosts win; the operator's lines after it are kept; an `Include config.d/*` line in the block |
|
||||
| `~/.ssh/config.d/<module>` | owned by the contributing module | ssh's own drop-in: a work module adds its forge's host there (research 026 C1) |
|
||||
| `~/.ssh/authorized_keys` | written into, the mesh's block | the operator's keys as the mesh records them, and nothing a retired system left. The operator's own lines are kept below the block |
|
||||
| `~/.ssh/known_hosts` | written into, the mesh's block | every mesh machine's host key, so the first connection never asks |
|
||||
| private keys | found | never read and never written by the mesh; a key the mesh should hand out comes from the vault, through the module's own process (ADR 0182, third class) |
|
||||
|
||||
The sshd module is the other half: the machine's side. It is already in the catalogue.
|
||||
|
||||
## Scripts on every machine, shared and machine-specific
|
||||
|
||||
*"All nodes should get some custom scripts, both node-specific and mesh-specific (shared)."*
|
||||
|
||||
**Measured:** the operator's script folder holds 64 entries plus 33 in its `bin/`. It is under no
|
||||
version control, and exists only where it was copied. It mixes three kinds:
|
||||
|
||||
1. scripts belonging to a module (the desktop's watchers, lock, menus; a laptop model's brightness);
|
||||
2. the operator's own tools;
|
||||
3. installers that modules have replaced.
|
||||
|
||||
**Starting position:**
|
||||
|
||||
- **The operator's scripts live in a repository of their own,** registered as any application is
|
||||
([ADR 0015](../../02-DECISIONS/0015-applications-live-in-their-own-repository.md)), built as archives,
|
||||
unpacked into a directory the module owns under the home. `bin/` goes on `PATH` through an
|
||||
environment contribution ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)),
|
||||
and small functions go into the shell through a `shell` contribution
|
||||
([ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)).
|
||||
- **"Machine-specific" is said by assignment, never by naming a machine**
|
||||
([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md)). One repository
|
||||
holds several modules:
|
||||
- `scripts` (shared, on every machine);
|
||||
- `scripts-workstation`;
|
||||
- `scripts-media`;
|
||||
- and so on, each assigned where it applies.
|
||||
|
||||
A script that belongs to a piece of software or hardware moves into that module instead. A flavor
|
||||
inside one module is what [research 026/03](../026-the-graphical-session-as-modules/03-what-the-predecessor-taught.md)
|
||||
says not to repeat.
|
||||
- **A script can also be a tool.** A script with a one-line description is served by the node's
|
||||
runtime, so it can be called through the mesh on any machine that has it.
|
||||
- A script that needs a secret gets it through question 2's mechanism, never from a file of
|
||||
environment secrets.
|
||||
|
||||
## The keyring
|
||||
|
||||
*"A keyring is also a good thing to create a module for."*
|
||||
|
||||
**Measured on the two workstations, which both run GNOME Keyring:**
|
||||
|
||||
- **On one, the keyring unlocks at login.** The login manager's PAM service includes `login`, which
|
||||
carries `pam_gnome_keyring`.
|
||||
- **On the other, it does not.** The PAM line is only in the screensaver's service, so at session
|
||||
start the window manager runs a script that asks for the password a second time and unlocks the
|
||||
keyring with it.
|
||||
- **On both, the session's start script starts the daemon again** with the ssh and gpg components,
|
||||
and exports the ssh agent's socket. The keyring's current release serves the ssh agent through a
|
||||
separate per-user socket unit instead.
|
||||
|
||||
**Starting position:** a `gnome-keyring` module that holds a node seat, `node-secret-service` (the
|
||||
holder of the desktop's secret service; a password manager could hold it instead). It declares:
|
||||
|
||||
- the package;
|
||||
- its lines in the login manager's PAM file, written into, never over (ADR 0102), so login unlocks it
|
||||
on every machine;
|
||||
- the ssh agent's user socket, once user-scoped units ship;
|
||||
- the agent's socket path as an environment contribution, which needs a machine fact for the
|
||||
account's runtime directory. ADR 0203 forbids `$` in values, so `$XDG_RUNTIME_DIR` cannot be
|
||||
written in one.
|
||||
|
||||
The second unlock prompt and the second daemon start go away.
|
||||
|
||||
## Mail as events
|
||||
|
||||
*"Ideally a mail consumer with all my mail accounts configured, so my mail is recorded in the bus."*
|
||||
|
||||
**Measured:**
|
||||
|
||||
- The predecessor polled one work mailbox every minute. It **read an access token out of the mail
|
||||
client's process memory**, called a mail API with it, and raised a desktop notification per unread
|
||||
message. It worked only while the mail client ran, and stopped silently when the predecessor's units
|
||||
were retired.
|
||||
- Two further predecessor modules served mail tools, for one provider and for IMAP.
|
||||
- The mesh runs a mail server of its own for its domains.
|
||||
|
||||
**Not decided here; it needs an effort of its own.** The questions it would have to answer:
|
||||
|
||||
- **Accounts and how each authenticates:**
|
||||
- IMAP with an app password;
|
||||
- a provider's OAuth with a registered application;
|
||||
- the mesh's own mail server, which can publish delivery itself.
|
||||
|
||||
An employer's tenant may forbid registering an application at all.
|
||||
- **What the bus records:**
|
||||
- headers and a summary as events;
|
||||
- bodies and attachments in an object store the event points at;
|
||||
- retention, since mail is the most personal data the mesh would hold.
|
||||
- **What consumes it:** a notifier bridge to the desktop (the predecessor's notifications), search,
|
||||
an agent's context.
|
||||
- **Where it runs:** one long-running module, not per machine (ADR 0198).
|
||||
|
||||
The obvious first step is the mail server the mesh already runs.
|
||||
|
||||
## Power management on the laptop
|
||||
|
||||
*"Power management for the laptop."*
|
||||
|
||||
**Measured on the laptop** (a gaming model with a hybrid GPU):
|
||||
|
||||
- **The platform profile is driven by a vendor daemon** (`asusd`) and its CLI. The vendor CLI is
|
||||
now in the official repositories; the copy installed came from elsewhere. A predecessor script
|
||||
runs as a user unit and switches the profile every five seconds: quiet on battery, balanced on
|
||||
mains, performance above 50 % CPU.
|
||||
- **The hybrid GPU's mode** (now hybrid) is held by a second vendor daemon (`supergfxd`), which is
|
||||
**not** in the official repositories. Kernel-module options for the discrete GPU's power state
|
||||
and its suspend, hibernate and resume units are set by hand. Its own power daemon is masked.
|
||||
- **The battery charge limit is 80 %,** set by the vendor daemon.
|
||||
- **The lid and power key suspend.** The brightness key is ignored by logind and handled by the
|
||||
vendor-key path. Both are logind drop-ins.
|
||||
- **Memory pressure:** compressed swap in RAM (`zram`) beside a swap file and a partition;
|
||||
`systemd-oomd` with drop-ins; a predecessor *memory guard* user unit that notifies before the OOM
|
||||
killer acts.
|
||||
- `upower` runs. There is no `power-profiles-daemon`, `tlp`, `auto-cpufreq` or `thermald`, so nothing
|
||||
competes with the vendor daemon, by design.
|
||||
|
||||
All of it came from two predecessor modules, one of which was a laptop-model *flavor*. A desktop
|
||||
received part of it (research 026/01).
|
||||
|
||||
**Starting position:**
|
||||
|
||||
- **A hardware module per machine model** (here, the laptop's model). It holds the vendor daemon and
|
||||
its profile configuration, the GPU mode daemon (ADR 0205's case, or the build machine's package
|
||||
repository of research 027 question 1), the discrete GPU's module options and suspend units, the
|
||||
logind drop-ins, the battery charge limit, and the vendor keys and brightness. It is assigned to
|
||||
the one machine of that model, and to any second one later.
|
||||
- **The profile switching** moves from a polling script to the module's own long-running code
|
||||
(ADR 0198). It reacts to the power-supply change event instead of polling, and its thresholds
|
||||
become settings (issue 168).
|
||||
- **Memory pressure is not the laptop's alone.** `zram` and `systemd-oomd` with the notifier are a
|
||||
`memory-pressure` module, assigned wherever wanted. The swap layout stays the machine's (`kernel`
|
||||
module, question 3).
|
||||
- A **`node-power-profile`** seat (vendor daemon, or `power-profiles-daemon` on other hardware)
|
||||
gives the mesh one verb, `profile`, the same on every machine that has one.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
status: active
|
||||
initiated: 2026-10-04
|
||||
touches:
|
||||
- 04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md
|
||||
- 04-ISSUES/229-a-rollout-cannot-be-followed-through-the-meshs-tools/00-report.md
|
||||
- 04-ISSUES/230-a-host-that-hands-over-to-a-newer-one-loses-its-report-and-a-plan-waits-for-ever/00-report.md
|
||||
- 04-ISSUES/233-a-host-without-its-package-managers-configuration-refuses-the-declaration-that-would-restore-it/00-report.md
|
||||
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
|
||||
- 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md
|
||||
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
|
||||
- 03-DESIGN/01-to-be/32-what-a-module-declares.md
|
||||
became: []
|
||||
---
|
||||
|
||||
# 028 — The mesh's output channel
|
||||
|
||||
## What
|
||||
|
||||
How the mesh tells its operator what it noticed. The operator's framing: sending notifications
|
||||
is **an output channel for the mesh**. The mesh already knows when a machine stops answering, when
|
||||
a failure repeats and will not fix itself, when a rollout waits for ever. Today it keeps that to
|
||||
itself until someone asks.
|
||||
|
||||
The effort looks at:
|
||||
|
||||
- **the seat:** one, held once for the mesh, that every other part uses to say something to the
|
||||
operator;
|
||||
- **the channels**, each a module: a desktop notification on the machine the operator is at,
|
||||
**Telegram**, a phone push service, chat, mail and others (see [02](02-the-channels.md));
|
||||
- **the routing**, by severity and by where the operator is;
|
||||
- **the life of a message:** deduplicated while it holds, resolved when it stops, acknowledged or
|
||||
silenced by the operator;
|
||||
- **the watcher's watcher:** who tells the operator when the parts that would tell them are the
|
||||
ones that failed.
|
||||
|
||||
## Why
|
||||
|
||||
[Issue 187](../../04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md) is the
|
||||
class: *the mesh tells nobody when it stops working*. [01](01-what-the-mesh-already-knows.md)
|
||||
counts it.
|
||||
- 15 of the 236 issue reports say the fault was found because a person happened to look.
|
||||
- 74 describe something failing silently.
|
||||
|
||||
On the day this effort opened, the mesh knew three things and told nobody:
|
||||
- a workstation had refused every declaration for ninety minutes;
|
||||
- the same workstation had been out of touch for ten minutes after an upgrade;
|
||||
- one failure on the laptop had repeated thirteen times.
|
||||
|
||||
Every one of them was in `status`, for whoever asked.
|
||||
|
||||
The pieces exist. [To-be 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) already uses a
|
||||
`telegram-sender` seat as its worked example of a work queue with retention. ADR 0208 made a
|
||||
machine's desktop notifier a node seat with a `send` verb. What is missing is a seat that speaks
|
||||
for the mesh, sources that call it, and channels that deliver.
|
||||
|
||||
## What it touches
|
||||
|
||||
- **The controller**, which would become the first source of what it already computes for `status`.
|
||||
- **The node-notifier seat**, which would become one channel among several.
|
||||
- **Issues 187, 229, 230 and 233**, each of which ends in "and nothing said so".
|
||||
- **ADR 0210**, because a channel extends the output seat through a contribution, and therefore
|
||||
depends on it.
|
||||
|
||||
## Documents
|
||||
|
||||
1. [What the mesh already knows](01-what-the-mesh-already-knows.md): the evidence, and the events
|
||||
that exist.
|
||||
2. [The channels](02-the-channels.md): the candidates, Telegram first, weighed on the same questions.
|
||||
3. [Open questions](03-open-questions.md): the seat, routing, life of a message, the watcher's
|
||||
watcher, what may leave the mesh.
|
||||
@@ -0,0 +1,60 @@
|
||||
# 01 — What the mesh already knows, and who hears it
|
||||
|
||||
## The count
|
||||
|
||||
Over the 236 issue reports in `04-ISSUES/`, on the day this effort opened:
|
||||
|
||||
- **15** say, in some wording, that a person found the fault by looking: "nobody was told",
|
||||
"nothing logged / said / alerted / emitted", "a person asked", "found by a person". Five of them
|
||||
are still open.
|
||||
- **74** describe something that failed silently.
|
||||
|
||||
The search was a word match over the reports' text, so it undercounts reports that tell the same
|
||||
story in other words. It never overcounts by much: each of the fifteen was read.
|
||||
|
||||
The fifteen fall into three groups:
|
||||
|
||||
- **The mesh knew, and kept it in a query.** The fault was in `status`, `plans` or a node's record,
|
||||
for whoever asked. Examples: a rollout waiting for ever (230), a machine refusing every declaration
|
||||
(233), a setting that cannot work stored and stopping the node (096).
|
||||
- **The fault was in a log nothing reads.** Examples: the bus refusing the controller's publishes
|
||||
(187), a dropped report (187, 230).
|
||||
- **The fault was invisible to the mesh itself.** Examples: a resolver outside the mesh closed by its
|
||||
filter (198), a port narrowed without saying (086).
|
||||
|
||||
Only the first group is a matter of telling: the fact exists, and only delivery is missing. The
|
||||
other two need a source first. This effort is about the first, and about giving the other two a
|
||||
place to say something once they can.
|
||||
|
||||
## What the controller computes and does not say
|
||||
|
||||
Read from `status` and `node show` on the day this effort opened. Each line is a fact the controller
|
||||
already holds:
|
||||
|
||||
| fact | where it is today | example that day |
|
||||
|---|---|---|
|
||||
| a machine is out of touch | `node show`: "last heard from — out of touch 10m" | a workstation after an upgrade |
|
||||
| a machine refused its declaration | `status`: "refused" with the reason | the same workstation, for 90 minutes |
|
||||
| a failure repeats and will not fix itself | `status`: "stuck: the same failure N times since …" | 13 times on the laptop |
|
||||
| machines run different hosts | `status`: the version table | after a host release |
|
||||
| something runs that the mesh did not write | `node show`: strays | 16 containers on one machine |
|
||||
| a filter rule the mesh did not write | `status` | one machine |
|
||||
| a plan is waiting | `plans` | issue 230: "for 0s", for ever |
|
||||
| an assignment does not compose | the `assign` answer only | issue 235 |
|
||||
|
||||
None of these is published. The bus carries a seat's own events (a build's outcome), a module's
|
||||
declared events, tool calls and declarations. It carries no event for any line above.
|
||||
|
||||
## What exists to deliver with
|
||||
|
||||
- **A machine's desktop:** the `node-notifier` seat (ADR 0208), held on the laptop. Its `send` verb
|
||||
shows a notification, and `history` lists them. It was used through the console the day this effort
|
||||
opened.
|
||||
- **Mail:** a mail module provides `smtp` to the mesh.
|
||||
- **Chat:** a Matrix server runs as a module on the home server.
|
||||
- **Home automation:** a home-automation module runs there too, and its phone app can receive pushes.
|
||||
- **A seat shape for exactly this:** to-be 32 §5 uses `telegram-sender` (`accepts: send`,
|
||||
`retain 7d`, `emits: delivered, failed`, `serves: status`) as its worked example. A seat's stream
|
||||
exists from registration, so work queues until a holder appears.
|
||||
|
||||
No module sends to Telegram, a phone push service or SMS today.
|
||||
@@ -0,0 +1,116 @@
|
||||
# 02 — The channels
|
||||
|
||||
Each channel is a candidate module that delivers what the output seat hands it. They are weighed on
|
||||
the same questions:
|
||||
|
||||
- **Reach:** does it reach the operator away from the machines (phone), or only at a desk?
|
||||
- **Off-mesh:** does it still work when the mesh's own parts (the bus, the controller, the control
|
||||
node's network) are what failed?
|
||||
- **Two-way:** can the operator answer through it: acknowledge, silence, ask?
|
||||
- **Where the words go:** does the message leave the operator's own machines, and to whom?
|
||||
- **What it costs to hold:** a secret, a server, an account, money.
|
||||
|
||||
## The candidates
|
||||
|
||||
### Telegram (required by the operator)
|
||||
|
||||
A bot created with Telegram's bot service sends to one chat: the operator's own, or a group.
|
||||
|
||||
- **Reach:** the phone and every desktop, with push.
|
||||
- **Off-mesh:** sending needs only outbound HTTPS from any machine. No inbound port, no server of the
|
||||
mesh's own. A second machine can hold the same bot token and send when the first is the one that
|
||||
failed.
|
||||
- **Two-way:** yes. Inline buttons on a message (acknowledge, silence for an hour) and commands to
|
||||
the bot, read by long polling over outbound HTTPS. This makes Telegram the strongest candidate for
|
||||
answering, and the riskiest (see [03](03-open-questions.md), Q7).
|
||||
- **Where the words go:** to Telegram's servers. Bot chats are not end-to-end encrypted. What a
|
||||
message may contain is therefore a rule this effort must set.
|
||||
- **Cost:** one secret (the bot token) and the chat's id. Free. Rate limits are far above what an
|
||||
operator should receive.
|
||||
- **Formatting:** short text with a little markup, buttons and links. Enough for a subject, a
|
||||
machine role, a severity and one line of why.
|
||||
|
||||
### The desktop notifier (exists)
|
||||
|
||||
The `node-notifier` seat's `send` verb on the machine the operator is at.
|
||||
|
||||
- **Reach:** only at that machine, only while a session is up.
|
||||
- **Off-mesh:** no. It is reached through the mesh's tools.
|
||||
- **Two-way:** dunst has actions, which a click can answer, but nothing reads them back yet.
|
||||
- **Where the words go:** nowhere; it is local.
|
||||
- **Cost:** none.
|
||||
- **Its place:** the gentlest channel, for a warning while the operator is at a desk. "At a desk" is
|
||||
itself a question: an unlocked session on a machine with recent input.
|
||||
|
||||
### ntfy (or Gotify): a self-hosted phone push
|
||||
|
||||
A small server publishes topics; its phone app subscribes.
|
||||
|
||||
- **Reach:** the phone, with push.
|
||||
- **Off-mesh:** only if the server runs outside what failed. On the control node it fails with it.
|
||||
- **Two-way:** action buttons can call a URL, which is an inbound path to design.
|
||||
- **Where the words go:** stays on the operator's machines when self-hosted. ntfy's iOS push passes
|
||||
through an upstream relay unless configured otherwise.
|
||||
- **Cost:** a module with a container and a routed name; a token per topic.
|
||||
|
||||
### Matrix (a server exists as a module)
|
||||
|
||||
A bot account posts to a room the operator is in.
|
||||
|
||||
- **Reach:** phone and desktop through any Matrix client.
|
||||
- **Off-mesh:** no, the server is one of the mesh's modules.
|
||||
- **Two-way:** yes, by messages to the bot.
|
||||
- **Where the words go:** stays on the operator's server, end-to-end encrypted if the bot supports it.
|
||||
- **Cost:** a bot account, a secret.
|
||||
|
||||
### Mail (a mail module provides `smtp`)
|
||||
|
||||
- **Reach:** everywhere, without urgency.
|
||||
- **Off-mesh:** no, if the mesh's own mail server sends. Yes, through an outside relay.
|
||||
- **Two-way:** no, not usefully.
|
||||
- **Its place:** the record and the digest: a daily summary of what was said and resolved, and the
|
||||
fallback when nothing else acknowledged.
|
||||
|
||||
### The home-automation companion app (a module exists)
|
||||
|
||||
Its phone app takes pushes and actionable notifications, and the home has lights and speakers.
|
||||
|
||||
- **Reach:** the phone, and the house itself: a light that turns a colour.
|
||||
- **Off-mesh:** no, the home server is a node.
|
||||
- **Its place:** a playful critical channel, not a primary one.
|
||||
|
||||
### The bar on the desktop
|
||||
|
||||
An `i3status-rust` block showing the count of open messages, red while one is critical.
|
||||
|
||||
- **Reach:** the desk only, and silent.
|
||||
- **Its place:** the ambient state. Nothing interrupts the operator, and they always see whether
|
||||
something is open.
|
||||
|
||||
### The console (an agent session)
|
||||
|
||||
A message the next agent session opens with ("two things happened while you were away").
|
||||
|
||||
- **Its place:** context for the agent working on the mesh rather than an alert. It falls out of the
|
||||
message store if the store is queryable.
|
||||
|
||||
### Others, noted and not pursued now
|
||||
|
||||
- **SMS or a voice call** through a paid gateway. It is the only channel that works with no data
|
||||
connection, and the only one that costs per message.
|
||||
- **Signal**, through an unofficial client: no bot API, and a registered number.
|
||||
- **Discord or Slack** webhooks: the words go to a third party, as with Telegram, without its two-way
|
||||
strength.
|
||||
- **Pushover:** paid, closed, and a phone push service much like ntfy.
|
||||
- **An external dead-man service** (a heartbeat URL that alerts when pings stop). It belongs to
|
||||
[03](03-open-questions.md), Q6, as the watcher's watcher rather than as a channel.
|
||||
|
||||
## A first reading
|
||||
|
||||
- **Telegram** is the primary phone channel, and the only candidate that is cheap, off-mesh capable
|
||||
and two-way at once.
|
||||
- **The desktop notifier** is for the desk.
|
||||
- **The bar** shows the ambient state.
|
||||
- **Mail** carries the digest and the record.
|
||||
- **ntfy and Matrix** are self-hosted alternatives for an operator who keeps words off third parties.
|
||||
The seat must make that a choice, not a rewrite.
|
||||
@@ -0,0 +1,127 @@
|
||||
# 03 — Open questions
|
||||
|
||||
Each question names the options seen so far. None is decided here.
|
||||
|
||||
## Q1. The seat
|
||||
|
||||
**What speaks for the mesh to its operator?**
|
||||
|
||||
- **a.** One seat in the mesh's own set, held once for the mesh. Working name: `operator-channel`.
|
||||
- It **accepts** `notify` (a work queue, as to-be 32 §5 designs `telegram-sender`), so a message
|
||||
waits until a holder appears.
|
||||
- It **emits** `delivered`, `acknowledged` and `resolved`.
|
||||
- It **serves** `open` (what is unresolved now) and `history`.
|
||||
- **b.** No seat: every source calls every channel. Rejected in advance, because each source would
|
||||
learn every channel. This is the inversion ADR 0126 exists to prevent.
|
||||
- **c.** Each channel as its own seat, with routing in the sources. Same objection as b, one level up.
|
||||
|
||||
Under a, the holder routes. The channels are modules that **contribute** themselves to the seat
|
||||
(ADR 0210): a channel extends the seat, and so depends on it. Whether the holder is a module of its
|
||||
own or part of the controller is open. A module keeps the controller small. The controller already
|
||||
holds most of the facts.
|
||||
|
||||
## Q2. What a message is
|
||||
|
||||
The first shape seen: a **subject** (what it is about: a machine's role, a module, a plan), a
|
||||
**kind** (out of touch, refused, stuck, late, …), a **severity**, a one-line **why**, a link to
|
||||
the tool that shows more, and a **key** that makes it the same message the next time it is said.
|
||||
|
||||
- **Severity:** two levels (needs you now / when you can), or three (critical / warning / info)?
|
||||
Every extra level is a routing rule somebody must keep right.
|
||||
- **The key** is what makes deduplication possible. "Machine X out of touch" said every minute is
|
||||
one message, still open, not sixty.
|
||||
|
||||
## Q3. The life of a message
|
||||
|
||||
open → (acknowledged) → resolved.
|
||||
|
||||
- **Deduplicate** by key while open.
|
||||
- **Resolve** when the source stops saying it, or says it is over ("back in touch after 14 min"). A
|
||||
channel that can edit its message (Telegram can) updates it in place rather than sending a second.
|
||||
- **Acknowledge** from any channel that can answer, which stops escalation and repeats.
|
||||
- **Repeat or escalate** an unacknowledged critical message after a while, to the next channel.
|
||||
- **Where the open set lives:** the seat's own state, in a key-value bucket (to-be 32's `state:`), so
|
||||
`open` answers after a restart.
|
||||
|
||||
## Q4. Routing and presence
|
||||
|
||||
- **By severity:** critical goes to every channel at once. A warning goes to the desk when the
|
||||
operator is at one, otherwise to the phone, otherwise to the digest.
|
||||
- **Presence:** "at a desk" needs a fact the mesh does not hold yet. Candidates: an unlocked
|
||||
graphical session with recent input, read from the `node-lock-screen` and `node-login-manager`
|
||||
seats' holders. Nothing more invasive.
|
||||
- **Quiet hours:** a setting of the seat's holder (ADR 0174). Critical overrides it, or not, as the
|
||||
operator chooses.
|
||||
- **Rate:** a cap per hour per channel, with the excess folded into one summary, so a storm (a
|
||||
network outage where every node is out of touch) arrives as one message naming many.
|
||||
|
||||
## Q5. The sources
|
||||
|
||||
The first sources are the facts in [01](01-what-the-mesh-already-knows.md), all in the controller
|
||||
today:
|
||||
|
||||
- a machine out of touch;
|
||||
- a declaration refused;
|
||||
- a stuck failure;
|
||||
- a plan late (once issue 230 gives a wait an age);
|
||||
- an assignment that does not compose (issue 235);
|
||||
- a host version split.
|
||||
|
||||
**How each becomes an event:**
|
||||
- **a.** The controller emits an event per change of state, and the seat's holder consumes them.
|
||||
- **b.** The controller calls `notify` itself.
|
||||
|
||||
With a, the controller learns nothing about telling: other consumers (a board, a log) get the same
|
||||
facts, and the holder decides what is worth a message. With b, the controller decides severity.
|
||||
|
||||
**Modules as sources:** a module may `use` the seat to tell the operator something of its own
|
||||
(a backup failed, a certificate is close to expiry), with the same message shape.
|
||||
|
||||
## Q6. The watcher's watcher
|
||||
|
||||
When the controller, the bus or the control node is what failed, nothing above runs. Options:
|
||||
|
||||
- **A dead-man signal:** the seat's holder sends a heartbeat out of the mesh (a ping to an external
|
||||
heartbeat service), which alerts the operator by its own means when pings stop.
|
||||
- **A second holder of the Telegram channel on another machine** that sends directly, without the
|
||||
bus, when it stops hearing the controller for longer than a bound.
|
||||
- **Each host** sending a last message itself when it loses the mesh for longer than a bound. This
|
||||
needs the channel's secret on every machine, a cost to weigh.
|
||||
|
||||
The first is the cheapest and the only one that also covers "the whole house is offline".
|
||||
|
||||
## Q7. Answering back
|
||||
|
||||
Telegram, and Matrix, can carry the operator's answers.
|
||||
|
||||
- **Acknowledge and silence** are safe: they change only the message's state.
|
||||
- **Commands** ("push the workstation", "show status") turn a chat account into a door to the
|
||||
controller. If it ever comes, it needs:
|
||||
- its own record;
|
||||
- a narrow verb set;
|
||||
- a check that the answer came from the operator's own account and chat;
|
||||
- and probably a confirmation step.
|
||||
|
||||
The first version should probably answer with acknowledge and silence only.
|
||||
|
||||
## Q8. What may leave the mesh
|
||||
|
||||
Telegram, and any third-party channel, carries the words to someone else's servers. A message
|
||||
names a machine, a module and a reason, which is operational detail.
|
||||
|
||||
- **What may a message contain?** Roles rather than addresses; no secrets, tokens or paths; a reason
|
||||
in words. The rule must be enforced by the seat's holder, not hoped for from each source.
|
||||
- **Is a self-hosted channel required for anything above a severity?**
|
||||
- **The bot token and chat id** are secrets of the channel's module, delivered as any module secret is.
|
||||
|
||||
## Q9. How it is checked
|
||||
|
||||
A rule this effort produces must say how it is verified. Candidates:
|
||||
|
||||
- a message said twice with one key is one message;
|
||||
- a resolved source resolves its message;
|
||||
- a critical message reaches every channel within a bound;
|
||||
- a message containing an address or a secret is refused;
|
||||
- the dead-man signal fires when the holder is stopped.
|
||||
|
||||
Each is a test of the holder, or a live drill: stop a machine's host and time the message.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
status: active
|
||||
initiated: 2026-10-04
|
||||
touches:
|
||||
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
|
||||
- 03-DESIGN/00-as-is/15-the-agent-and-its-licences.md
|
||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
||||
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
|
||||
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
|
||||
- 02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md
|
||||
became: []
|
||||
---
|
||||
|
||||
# 029 — The agent configured through its module
|
||||
|
||||
## What
|
||||
|
||||
Everything about the operator's coding agent that can be configured is configured through the agent
|
||||
module's tools, and reaches the machines as **one plugin the mesh serves**:
|
||||
|
||||
- subagents, skills, slash commands, hooks, output styles and tool servers;
|
||||
- the agent's settings and its instructions.
|
||||
|
||||
Each item is registered once, from any machine, and goes to one machine, several, or all of them,
|
||||
including a machine that joins later. The mechanism is the one the module already uses for tool
|
||||
servers: the registration is kept in the module's state on the bus, and each machine's instance writes
|
||||
what applies to it. The operator chose the plugin route on the day this effort opened.
|
||||
|
||||
**Three scopes** (the operator's direction, the same day):
|
||||
|
||||
- **mesh:** in the mesh's plugin and the managed files, on every machine;
|
||||
- **node:** the same places, rendered for one machine or a list of them;
|
||||
- **home:** placed in the operator account's own agent directory on a machine, beside what the
|
||||
person writes there by hand.
|
||||
|
||||
Instructions follow the same scopes: the mesh's piece, the node's piece, then further customisation per
|
||||
machine. See [03](03-options.md).
|
||||
|
||||
## Why
|
||||
|
||||
The vendor gives the agent a machine-wide directory for its settings, its tool servers and one
|
||||
instruction file, and **nothing machine-wide for skills, subagents, commands or hooks**. Those exist
|
||||
only in a home or a project. So today they are copied into each home by hand, and they drift and go
|
||||
stale. [01](01-what-is-configured-today.md) measures that on four machines.
|
||||
|
||||
A plugin is the vendor's own unit for carrying all of those at once. A machine-wide setting can name
|
||||
a marketplace and enable a plugin from it. If the module serves the plugin and its own managed settings
|
||||
enable it, the mesh gets a machine-wide place for everything the vendor left home-only, and the home
|
||||
stays the person's ([ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)).
|
||||
|
||||
## What it touches
|
||||
|
||||
- **The agent module's design** ([to-be 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)):
|
||||
its managed directory, its state, and its tools.
|
||||
- **Module state** ([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)):
|
||||
where registrations are kept, and which file content fits in a bucket.
|
||||
- **Contributions** ([ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)):
|
||||
a module other than the agent's, say the forge's, wanting the agent to have a skill for it. That is a
|
||||
contribution to the agent's seat, not a file it writes.
|
||||
- **The managed settings key the operator sets** ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)),
|
||||
which a settings tool would write rather than a hand-composed settings layer.
|
||||
|
||||
## Documents
|
||||
|
||||
- [01 — What is configured today](01-what-is-configured-today.md): the evidence.
|
||||
- [02 — What the vendor allows](02-what-the-vendor-allows.md): plugins, marketplaces and managed
|
||||
settings, as documented, with sources.
|
||||
- [03 — Options](03-options.md): where each kind of item goes, how it is registered and stored, and
|
||||
the questions a decision has to answer.
|
||||
- [04 — What was confirmed](04-what-was-confirmed.md): the checks, tried on one workstation.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# 01 — What is configured today
|
||||
|
||||
Measured on 2026-10-04 on four machines that run the agent module: two workstations, the control node
|
||||
and a home server. The figures count what sits in each operator account's agent directory in its home,
|
||||
outside the module's managed directory.
|
||||
|
||||
## What sits in the homes
|
||||
|
||||
| what | workstation A | workstation B | control node | home server |
|
||||
|---|---|---|---|---|
|
||||
| rule files (`rules/`) | 4 | 2 | 2 | 1 |
|
||||
| skills of the person's own (`skills/`, beside the vendor's synced ones) | 6 | 2 | 2 | 2 |
|
||||
| subagents (`agents/`) | 0 | 1 | 0 | 0 |
|
||||
| slash commands (`commands/`) | 0 | 0 | 0 | 0 |
|
||||
| plugin marketplaces known | 1 | 2 | 1 | 1 |
|
||||
|
||||
## What that shows
|
||||
|
||||
- **Six files the design says the operator removes are still on every machine.** To-be 36 §1 lists the
|
||||
predecessor's rule files and skills and leaves their removal to the operator, "once, on each
|
||||
workstation". On all four machines, the two predecessor skills are present, byte-identical to each
|
||||
other:
|
||||
- one that switches licences through tools that no longer exist;
|
||||
- one that names the predecessor's forge.
|
||||
|
||||
So a session can still load a skill whose every instruction fails.
|
||||
- **One instruction, three versions.** The predecessor's node-identity rule file is on three machines,
|
||||
with three different contents. It was written per machine and then left alone.
|
||||
- **Two instruction sets that contradict each other, loaded together.** On a workstation, one session
|
||||
reads two sets of instructions:
|
||||
- the module's managed instruction file says to search the mesh's records first;
|
||||
- the predecessor's rule files in the home say to search the predecessor's knowledge base first,
|
||||
through tools that are no longer served.
|
||||
|
||||
Both are loaded, and neither says the other is stale.
|
||||
- **A subagent exists on one machine only.** A reviewer for module definitions was written on one
|
||||
workstation. The other three machines cannot use it, and nothing says it exists.
|
||||
- **Settings are per home, and so per machine.** The agent's auto-mode environment, the rules that
|
||||
decide which actions the agent may take unasked, is written in one home's settings file. It
|
||||
describes another organisation's cloud, and it answers for this mesh's forge only through a list of
|
||||
trusted domains. When the agent refused a merge the operator had approved, the only lawful fix was
|
||||
a managed key ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)).
|
||||
The agent could not change its own settings, and nothing else in the mesh could either.
|
||||
|
||||
## What already works the way this effort wants
|
||||
|
||||
Tool servers. A server registered through the module's register tool is kept in the module's state
|
||||
on the bus, keyed `all.<server>` or `<node>.<server>`. Every instance watches that state and writes what
|
||||
applies to it into the managed tool-server file, and a machine that joins later takes it at its first
|
||||
start. Today one server is registered there, for one workstation. That is the shape this effort extends
|
||||
to everything else.
|
||||
@@ -0,0 +1,106 @@
|
||||
# 02 — What the vendor allows
|
||||
|
||||
Read from the vendor's documentation on 2026-10-04; the agent installed on the machines measured in
|
||||
[01](01-what-is-configured-today.md) was a 2.1 release. Each fact names the page it came from. Where the
|
||||
documentation is silent, this says so. A fact that a design rests on is to be confirmed on one machine
|
||||
before it is built on (see [03](03-options.md), *What to confirm first*).
|
||||
|
||||
## What a plugin can carry
|
||||
|
||||
A plugin is a directory with a manifest in `.claude-plugin/plugin.json` and, beside it, any of:
|
||||
|
||||
- skills, slash commands and subagents;
|
||||
- hooks;
|
||||
- tool servers (`.mcp.json`) and language servers;
|
||||
- output styles, workflows, themes and monitors;
|
||||
- a `bin/` directory;
|
||||
- a `settings.json`.
|
||||
|
||||
Its components are namespaced by the plugin's name, so a subagent `reviewer` in a plugin `mesh` is
|
||||
`mesh:reviewer`, and it never collides with a person's own of the same name.
|
||||
— *plugins/manifest-reference, plugins/loading (name conflicts)*
|
||||
|
||||
**What a plugin cannot carry:**
|
||||
|
||||
- **Settings.** Only two keys of a plugin's `settings.json` take effect: the default agent and the
|
||||
subagent status line. The rest are dropped. — *plugins/manifest-reference, settings*
|
||||
- **Permission rules.** Not documented as a plugin capability.
|
||||
- **Instructions.** A `CLAUDE.md` at a plugin's root is not loaded, and the validator warns about it.
|
||||
Instructions reach a session through skills only. — *plugins/manifest-reference, standard layout*
|
||||
|
||||
## Marketplaces, and a marketplace on the machine's own disk
|
||||
|
||||
A marketplace is a `marketplace.json` listing plugins and where each comes from. Its sources include:
|
||||
|
||||
- a relative path inside the marketplace;
|
||||
- a forge repository, a git URL or a subdirectory of one;
|
||||
- a package from a registry;
|
||||
- an archive over HTTPS;
|
||||
- the output of a command.
|
||||
|
||||
**A marketplace can be a directory on the machine.** Its plugins with relative paths are **loaded in
|
||||
place**, not copied into the cache. An edit takes effect at the next session start, or at
|
||||
`/reload-plugins` in a running session, and the plugin's version need not change.
|
||||
— *plugins/marketplace-reference (marketplace sources), plugins/loading (in-place and copied plugins)*
|
||||
|
||||
A plugin from any other source is copied into a cache in the home, under
|
||||
`plugins/cache/<marketplace>/<plugin>/<version>/`. — *plugins/loading*
|
||||
|
||||
## What managed settings do with plugins
|
||||
|
||||
These keys work in the machine-wide managed settings file — *plugins/org*:
|
||||
|
||||
| key | what it does |
|
||||
|---|---|
|
||||
| `extraKnownMarketplaces` | registers a marketplace on every session of the machine |
|
||||
| `enabledPlugins` | `true` installs and enables a plugin; `false` blocks and hides it at every scope. The managed value outranks every other scope |
|
||||
| `strictKnownMarketplaces`, `blockedMarketplaces` | allow-list or block-list of marketplace sources |
|
||||
| `strictPluginOnlyCustomization` | refuses skills, subagents, hooks and tool servers that come from neither a plugin nor managed settings |
|
||||
| `allowManagedHooksOnly` | runs only the hooks from managed sources |
|
||||
| `disableSideloadFlags` | blocks loading a plugin from the command line |
|
||||
| `syncClaudeAiPlugins` | stops plugins synced from the vendor's web account |
|
||||
|
||||
**Installed without anyone being asked.** Once the settings reach a machine, the marketplace is
|
||||
registered and the plugins installed at the next session start. A non-interactive run installs them in
|
||||
the background. Managed plugins do not wait for the workspace trust prompt. — *plugins/org*
|
||||
|
||||
## What the managed settings file honours besides
|
||||
|
||||
`permissions` (with its default mode and the switch that disables bypassing it), `autoMode`, `hooks`,
|
||||
`env`, `model`, `statusLine`, `outputStyle`, `apiKeyHelper`, and the managed-only switches for permission
|
||||
rules, hooks and tool servers. — *managed-settings*
|
||||
|
||||
That `autoMode` is honoured from the managed file is documented. That it changes what the agent
|
||||
refuses on these machines is still to be seen ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)
|
||||
left that open).
|
||||
|
||||
## Tool servers: the exclusive file wins over a plugin's
|
||||
|
||||
When the managed tool-server file is present, as the module writes it, it is **exclusive**: only its
|
||||
servers load. The vendor's web connectors load too when a managed key allows them. **A plugin's
|
||||
`.mcp.json` servers are blocked.** — *managed-mcp (exclusive control)*
|
||||
|
||||
So a tool server registered through the module stays in the managed tool-server file. Putting it in
|
||||
the plugin would silently stop it loading.
|
||||
|
||||
## Variables inside a plugin
|
||||
|
||||
- `${CLAUDE_PLUGIN_ROOT}`: the plugin's directory.
|
||||
- `${CLAUDE_PLUGIN_DATA}`: a directory that survives updates.
|
||||
- `${CLAUDE_PROJECT_DIR}`: the project's root.
|
||||
|
||||
These resolve in hook commands, tool and language server configuration, and the content of skills,
|
||||
subagents and commands. A plugin's declared options (`userConfig`) can be marked sensitive; the agent
|
||||
asks the person for them and stores them itself. — *plugins/manifest-reference (environment variables)*
|
||||
|
||||
## Reload
|
||||
|
||||
A running session does not see a changed plugin until `/reload-plugins` or a new session.
|
||||
`/reload-plugins` reloads skills, subagents, hooks and servers. It does not restart monitors.
|
||||
— *plugins/loading*
|
||||
|
||||
## Not documented
|
||||
|
||||
- a machine-wide directory for bare skills, subagents or commands. Only a plugin enabled by managed
|
||||
settings puts them machine-wide;
|
||||
- permission rules or instructions carried by a plugin.
|
||||
@@ -0,0 +1,159 @@
|
||||
# 03 — Options
|
||||
|
||||
The route is chosen: a plugin the mesh serves. What is left open is where each kind of item goes, how it
|
||||
is registered and kept, and what the module does about what it finds in the homes.
|
||||
|
||||
## Scopes (the operator's direction, 2026-10-04)
|
||||
|
||||
The plugin is not the only place the module manages. **The agent's configuration is managed at three
|
||||
scopes, and each item is registered at one of them:**
|
||||
|
||||
| scope | where it lands | reaches |
|
||||
|---|---|---|
|
||||
| **mesh** | the mesh's plugin, and the mesh's part of the managed files | every machine running the agent, including one that joins later |
|
||||
| **node** | the same plugin and managed files, as rendered on that machine | one machine, or a list of them |
|
||||
| **home** | the operator account's own agent directory on a machine (`~/.claude`) | that account on that machine |
|
||||
|
||||
Each machine renders its own plugin from the registrations that apply to it, so a node-scoped skill sits
|
||||
in the same `mesh` plugin as a mesh-scoped one, on that machine only. The home scope places an item
|
||||
where the person's own items live, without the plugin's prefix, as if written there by hand. The
|
||||
difference is that the mesh knows it placed the item and can change or remove it.
|
||||
|
||||
**Instructions follow the same scopes.** The agent reads the managed instruction file first, then the
|
||||
home's instruction file and its rule files, then the project's. These are concatenated, not overridden:
|
||||
a later file does not cancel an earlier one, which is how the contradiction measured in
|
||||
[01](01-what-is-configured-today.md) came about.
|
||||
|
||||
- **The mesh's piece** sits in the managed instruction file and is the same on every machine: how a
|
||||
session on this mesh works, and the conventions.
|
||||
- **The node's piece** sits in the same file, rendered per machine: its role, and instruction sections
|
||||
registered for it.
|
||||
- **Further customisation per machine** sits in the home: a rule file the module places, at the home
|
||||
scope, beside whatever the person writes there by hand.
|
||||
|
||||
**What the home scope needs from [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md).**
|
||||
That ADR already lets the mesh own what it places in a home and hold the rest as found. So the module
|
||||
owns each home item it placed, path by path, recorded in its state. It never writes, renames or
|
||||
removes an item it did not place. A home item with the same name as one the person made is refused
|
||||
at registration, never overwritten.
|
||||
|
||||
## Where each kind of item goes
|
||||
|
||||
[02](02-what-the-vendor-allows.md) puts a hard limit on the plugin: it carries skills, subagents, commands,
|
||||
hooks, output styles and language servers, but no settings, no permission rules, no instructions, and
|
||||
no tool server the exclusive managed file does not list. So there are four places, not one:
|
||||
|
||||
| kind | goes to | why there |
|
||||
|---|---|---|
|
||||
| skills, subagents, slash commands, output styles | **the mesh's plugin** | the only machine-wide place the vendor has for them |
|
||||
| hooks | **the mesh's plugin**, with the scripts beside them | a hook's script can live in the plugin and be named through `${CLAUDE_PLUGIN_ROOT}`. A hook in the managed settings would need its script placed somewhere else |
|
||||
| tool servers | **the managed tool-server file**, as today | the exclusive file blocks a plugin's servers |
|
||||
| settings and permission rules | **the managed settings file**, beside the mesh's keys ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)) | a plugin's settings are dropped |
|
||||
| instructions | **the managed instruction file**, in sections | a plugin's instruction file is not loaded |
|
||||
|
||||
The plugin is reached through two managed keys the module already owns the file for:
|
||||
`extraKnownMarketplaces`, naming a marketplace directory the module writes, and `enabledPlugins`, set to
|
||||
`true` for the mesh's plugin. Neither is the operator's to set. Like the attribution key, they are the
|
||||
mesh's keys and outrank whatever the operator sets.
|
||||
|
||||
### Option A — one plugin
|
||||
|
||||
Everything the mesh serves is in one plugin, `mesh`, so every invocation reads `mesh:<name>`. That is
|
||||
simple, and the name says where an item came from.
|
||||
|
||||
### Option B — a plugin per source
|
||||
|
||||
One plugin for what the operator registers, and one for what other modules contribute (below). An item
|
||||
then says in its name whether a person or a module definition put it there. But the operator has two
|
||||
prefixes to remember, and an item has two owners to ask about.
|
||||
|
||||
*Leaning:* A. Where an item came from belongs in the module's list tool, not in the item's name.
|
||||
|
||||
## Who registers an item
|
||||
|
||||
- **The operator, through the module's tools**, from any machine, for one, several or all of them. The
|
||||
pattern is the tool-server register tool's, extended to every kind:
|
||||
- `claude_code_<kind>_list`, `_register`, `_unregister` for skills, subagents, commands, hooks, output
|
||||
styles and instruction sections;
|
||||
- `claude_code_settings_get` / `_set` and `claude_code_permission_allow` / `_deny` / `_ask` /
|
||||
`_remove` for the managed settings.
|
||||
- **Another module, through the agent's seat** ([ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)).
|
||||
The forge's module wanting the agent to know how pull requests are made here contributes a skill. It
|
||||
declares the contribution in its definition, and the controller renders it to the agent's holder on
|
||||
each machine where both run. That depends on the agent module holding a seat; today it holds none.
|
||||
|
||||
The two meet in the one plugin. A contribution and a registration with the same name are refused at
|
||||
registration, and the list tool shows the owner of each.
|
||||
|
||||
## Where a registration is kept
|
||||
|
||||
The tool-server registrations live in a key-value bucket the module declares
|
||||
([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)),
|
||||
keyed `all.<name>` or `<node>.<name>`. Skills differ: a skill is a folder, and it can carry scripts
|
||||
and reference files beside its main file. Every message on the bus is limited to about a megabyte.
|
||||
|
||||
1. **One value per item.** The item and its files go in one value, refused above a limit well under
|
||||
the bus's. It is simple, it fits the module's existing state, and every item measured in
|
||||
[01](01-what-is-configured-today.md) takes 20 KB or less on disk,
|
||||
the vendor's synced skills aside. But a skill with a large reference file cannot
|
||||
be registered at all.
|
||||
2. **The bus's object store for files, the bucket for the item.** Large files are stored in pieces and
|
||||
the item names them. Nothing in the mesh uses the object store yet, so ADR 0201 would need
|
||||
extending.
|
||||
3. **A repository on the forge.** The plugin is built from a repository, and registering an item is a
|
||||
commit. This is reviewable and versioned. But a register tool would have to write to the forge,
|
||||
and the forge would sit on the path to every machine.
|
||||
|
||||
*Leaning:* 1 now, with the limit stated and checked at registration. 2 when an item outgrows it. 3
|
||||
mixes the operator's configuration into the code review cycle, which it does not need.
|
||||
|
||||
## Where the plugin is written
|
||||
|
||||
The module owns the managed directory, so the marketplace goes under it, written whole by the
|
||||
module's code:
|
||||
|
||||
- the marketplace file;
|
||||
- one plugin directory beside it.
|
||||
|
||||
It is loaded in place, so a change takes effect at the next session, or at `/reload-plugins` in a
|
||||
running one. Nothing is copied into the home.
|
||||
|
||||
## What the module does about what it did not place
|
||||
|
||||
[01](01-what-is-configured-today.md) found stale predecessor files on every machine. The module did not
|
||||
place those, so they are held as found (ADR 0182). It can:
|
||||
|
||||
- **report** them: a status tool lists the home's skills, subagents, commands and rule files, says which
|
||||
the mesh placed, and names those that duplicate a mesh item or call tools no longer served;
|
||||
- **import** one on request: `claude_code_<kind>_import` takes an item from one machine's home and
|
||||
registers it at a scope the operator chooses. A skill written by hand on one workstation becomes a
|
||||
mesh, node or home item in one call. Removing the original stays the person's act.
|
||||
|
||||
`strictPluginOnlyCustomization` would make home items stop loading altogether, and home-scoped items
|
||||
with them. That is the operator's choice to make through the managed settings, not a default of the
|
||||
module.
|
||||
|
||||
## What to confirm first, on one workstation
|
||||
|
||||
1. A directory marketplace named in the managed settings, with its plugin enabled there, loads with
|
||||
no prompt, in place, in an interactive session and in a non-interactive one.
|
||||
2. The plugin's skills, subagents and commands are offered under `mesh:`, beside the home's own
|
||||
items, with no collision.
|
||||
3. A hook in the plugin runs, with its script found through `${CLAUDE_PLUGIN_ROOT}`.
|
||||
4. The exclusive tool-server file still loads the console, and a server in the plugin does not load,
|
||||
as documented.
|
||||
5. `autoMode` in the managed settings changes what the agent refuses (ADR 0213's open point).
|
||||
6. The account can read the marketplace in the managed directory, which root owns.
|
||||
7. The managed instruction file and a home rule file the module placed are both loaded, in that order.
|
||||
|
||||
## Questions a decision has to answer
|
||||
|
||||
- One plugin or one per source (leaning: one).
|
||||
- How a registration is kept, and the size limit (leaning: one value per item, with a stated limit).
|
||||
- Whether the managed settings are set through tools writing the module's state, or through the
|
||||
controller's settings layer as ADR 0213 has it. If both, which one wins on the same key.
|
||||
- Whether the agent module holds a seat, so that other modules can contribute to it.
|
||||
- The three scopes, and the home scope's ownership rule: the module owns exactly the home paths it
|
||||
placed, recorded in its state, and refuses a name the person already uses.
|
||||
- Whether settings take the same three scopes. The home's settings file is the person's own, so it is
|
||||
left out unless the operator chooses otherwise.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 04 — What was confirmed
|
||||
|
||||
On 2026-10-04, on one workstation running the agent's 2.1 release, the checks [03](03-options.md)
|
||||
listed were tried with a probe. The probe was a directory marketplace holding one plugin named
|
||||
`mesh`, which carried:
|
||||
|
||||
- a skill, a subagent and a slash command;
|
||||
- a session-start hook running a script in the plugin;
|
||||
- a tool server in the plugin's own `.mcp.json`.
|
||||
|
||||
The managed settings were set through the agent module's `managed_settings`
|
||||
([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)),
|
||||
on that machine's layer only, and sent by a push. The module rendered them into the managed file
|
||||
within seconds, without a restart.
|
||||
|
||||
| # | check | result |
|
||||
|---|---|---|
|
||||
| 1 | a marketplace named in the managed settings, with its plugin enabled there, loads with no prompt, in place | **confirmed.** A non-interactive session registered the marketplace and enabled the plugin at start, with nothing asked. The plugin was not copied into the home's plugin cache and is not listed among installed plugins: it is read where it lies |
|
||||
| — | a change to the plugin needs no version bump | **confirmed.** A skill added to the plugin's directory after the first session was offered by the next one |
|
||||
| — | a running session takes the plugin without being restarted | **seen.** An interactive session that was already running when the plugin was enabled offered its skills and subagent after the operator logged in again in that session, without a restart |
|
||||
| 2 | the plugin's items are offered under its name, beside the home's | **confirmed.** `mesh:probe-skill`, `mesh:probe-agent` and the command `/mesh:probe`. A collision with a home item of the same name was not tried |
|
||||
| 3 | a hook in the plugin runs, its script found through `${CLAUDE_PLUGIN_ROOT}` | **confirmed.** The session-start hook ran its script. The vendor's validator asks for the placeholder to be quoted |
|
||||
| 4 | the exclusive tool-server file still loads the console, and a plugin's server does not | **confirmed.** The session started the console and the registered servers, and never the plugin's server |
|
||||
| 5 | `autoMode` in the managed settings changes what the agent refuses | **very likely.** With a probe rule forbidding one harmless read-only command, a session that was already running had that command refused moments after the rule was rendered, though the refusal gave no reason. It also suggests the rule reached a running session without a restart. A clean check needs a session whose only difference is the rule; the agent may not start one in its own auto mode, so it is left to the operator |
|
||||
| 6 | the account can read the managed directory, which root owns | **confirmed** by what already runs: every session reads the managed instruction file from that directory |
|
||||
| 7 | the managed instruction file and the home's are both loaded, managed first | **confirmed** by what already runs: a session lists the managed instruction file first, then the home's instruction file, then each of the home's rule files |
|
||||
|
||||
## What this changes in the options
|
||||
|
||||
- The plugin route works as documented, with no file copied into the home. The module's managed
|
||||
directory can hold the marketplace.
|
||||
- **A tool server stays in the managed tool-server file.** Check 4 closes that.
|
||||
- **Undoing a managed setting is not the agent's to do.** When the probe was over, the agent tried to
|
||||
clear its own machine's settings layer, and its own auto mode refused that as self-modification.
|
||||
Setting it had been allowed only because it made the agent stricter. So the tools that set the
|
||||
agent's settings and permissions are tools the operator calls, and the agent calling them for
|
||||
itself is refused by the vendor's own guard. A design must not assume an agent can tidy up after
|
||||
itself.
|
||||
+2
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
|
||||
|
||||
# 174. A node varies a module through settings and kept regions, never through an edit
|
||||
|
||||
> **The mechanism changed — 2026-10-04, by [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md).** Where this record calls a kept region *a marked block in which the operator's own lines are kept*, read the inverse, which is what the host built: the mesh's region is the marked block, and every line outside it is the operator's, kept byte for byte and given back when the module goes. The decision stands: a node varies a module by settings and by the operator's own lines, never by an edit.
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0011](0011-managed-files-are-generated-never-edited.md) says a managed file is derived and an
|
||||
|
||||
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
|
||||
|
||||
# 176. The login shell is a node seat held by one shell module, and `execute` is its contract
|
||||
|
||||
> **The mechanism changed — 2026-10-04, by [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md).** The seat is no longer declared by the shell modules (§1). It is `node-login-shell`, in the mesh's own seat set, which a shell module claims. Its holder also places the shell code other modules contribute, and sources the account's environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)). What stands: one holder per node, the login shell set by the `user` shape and given back, `execute` as the contract, and any node may call it.
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0040](0040-what-a-module-is.md) names the shell as its example of a *shared* seat: bash, zsh
|
||||
|
||||
+9
-3
@@ -9,6 +9,12 @@ extends: 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||
|
||||
# 181. The operator account is a node fact, and a home is a placement root
|
||||
|
||||
> **Progressive insight — 2026-10-04.** This record called a resource under a home *home-scoped*, and a
|
||||
> module that places one a *home-scoped module*. There is no such kind of module
|
||||
> ([ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) §2: a module is
|
||||
> what it declares), so the three places now say *a resource placed under a home* and *a module placing
|
||||
> files under a home*. What was decided is unchanged.
|
||||
|
||||
*Reconstructed. The controller shipped this on 2026-09-27 and
|
||||
[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
|
||||
@@ -35,7 +41,7 @@ its home; the account and its home are machine facts a definition may name in a
|
||||
and content; a roster file may say it lives under the home, and is then rendered per node, placed under
|
||||
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.
|
||||
stated it, so no resource placed under a home can land anywhere yet.
|
||||
|
||||
## Considered Options
|
||||
|
||||
@@ -68,7 +74,7 @@ account. A definition names the account and its home as machine facts, never as
|
||||
may say it is a home file and is then placed and owned the same way. The controller resolves both at
|
||||
composition, and the host chowns what it creates.
|
||||
|
||||
**A node with no account cannot carry a home-scoped resource, and says so.** A roster fact that lives
|
||||
**A node with no account cannot carry a resource placed under a home, and says so.** A roster fact that lives
|
||||
under the home is left out of that node's declaration rather than written to nowhere. A resource naming
|
||||
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
|
||||
@@ -80,7 +86,7 @@ anything.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The operator states the account before any home-scoped module lands.** Today none is stated, so the
|
||||
- **The operator states the account before any module placing files under a home lands.** Today none is stated, so the
|
||||
first assignment of such a module begins with four node records.
|
||||
- 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.
|
||||
|
||||
+9
-3
@@ -9,6 +9,12 @@ extends: 02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-
|
||||
|
||||
# 182. Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found
|
||||
|
||||
> **Progressive insight — 2026-10-04.** This record said *a home-scoped module* and *the family of
|
||||
> home-scoped modules*. There is no such kind of module
|
||||
> ([ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) §2), and the rule
|
||||
> is about a directory under a home, whichever module declares it; the three places now say so. The
|
||||
> decision, its options and its consequences are unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) lets a module
|
||||
@@ -35,7 +41,7 @@ use tools that no longer exist. Nothing owns them; nothing will ever rewrite or
|
||||
`~/.ssh`: the mesh owns the directory and the files it places; it holds the person's private keys and
|
||||
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
|
||||
to hold for every directory under a home that any module will touch, so it is a rule, not a
|
||||
section.
|
||||
|
||||
## Considered Options
|
||||
@@ -52,7 +58,7 @@ section.
|
||||
|
||||
## Decision
|
||||
|
||||
**A home-scoped module owns the directory it declares: its existence, owner and mode.** The host creates
|
||||
**A module that declares a directory under a home owns that directory: its existence, owner and mode.** The host creates
|
||||
it if absent, owned by the account, and never removes it while it holds anything
|
||||
([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
|
||||
@@ -105,7 +111,7 @@ finished its definition.
|
||||
| An owned file found with no record is kept once, then written | host tests of ADR 0102's kept-original rule |
|
||||
| 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) |
|
||||
| Every path a module touches under a home is classified | a catalogue review rule for this family: each path is a directory, a file, a file written into, a secret-and-step, or absent — the first module written to it is [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) |
|
||||
|
||||
## References
|
||||
|
||||
|
||||
+24
@@ -152,6 +152,30 @@ the node is bound to, and refuses with a notification otherwise.
|
||||
| An unservable binding refuses rather than lends | a manager test: a worker bound to a dead licence is answered with a refusal, never another licence's token |
|
||||
| A switch through the console changes the token on the node and nothing in the answer is a token | a live check on one workstation |
|
||||
|
||||
> **The mechanism changed — 2026-10-03, by [ADR 0193](0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)
|
||||
> and [ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md).**
|
||||
> What stands: one manager holding the seat, one rotation source, a token sealed to the receiving
|
||||
> module's key on request/reply and never an event, the agent module alone writing what the agent
|
||||
> reads, the identity guard, the host knowing nothing. What moved: both modules' code is bundles the
|
||||
> node's runtime launches over stdio and is the bus for — `mesh/ask` for a call made on the module's
|
||||
> behalf, `mesh/publish` and `mesh/subscribe` beside it — so neither holds a bus credential of its own.
|
||||
> The manager's refresh and visits are a long-running bundle the control node's runtime launches. And,
|
||||
> by the operator's direction, **the manager starts every exchange**: it asks each bound node's agent
|
||||
> module for its public key, hands it a token, asks it for a login waiting to be adopted, and reconciles
|
||||
> every node on a schedule — which is what "the agent module asks the seat for its current token" and
|
||||
> "offers the grant to the manager" in the decision above now mean in practice. The agent module could
|
||||
> ask through its runtime; it does not need to.
|
||||
|
||||
> **The mechanism changed — 2026-10-04, by [ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md).**
|
||||
> What stands: the manager holding the seat, one rotation source, the grants encrypted in its store, a
|
||||
> token sealed to the receiving module's key on request/reply and never an event, the agent module alone
|
||||
> writing what the agent reads, the identity guard, bindings as a person's act. What moved: the dated note
|
||||
> above — the manager no longer starts every exchange. Each node reports what it holds as state, without
|
||||
> the secret; the manager asks a node for its grant only when a report shows one it does not hold, adopts
|
||||
> a licence by refreshing it rather than into a licence configured beforehand, and keeps what each
|
||||
> consumer should hold as state, from which the node fetches its token by request. The rotation and switch
|
||||
> events are gone.
|
||||
|
||||
## References
|
||||
|
||||
- [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
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md
|
||||
---
|
||||
|
||||
# 189. The store keeps what the records name, and a maintenance step holds its writers still
|
||||
|
||||
## Context
|
||||
|
||||
The mesh's artifact store has never collected anything
|
||||
([issue 108](../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md)).
|
||||
Every build pushes another layer set; nothing has ever removed one. The predecessor ran a routine
|
||||
on a timer — stop the registry, collect, start it — and the conversion carried the settings that
|
||||
routine depends on without the routine, because the routine was a script beside the module and not
|
||||
a resource in it. The store now holds fifty-three repositories on the machine that serves
|
||||
everything else, and the only outcome of leaving it is a full disk reported as somebody else's
|
||||
failure.
|
||||
|
||||
Three things stood in the way, and the issue names all three.
|
||||
|
||||
**Nothing in the mesh's vocabulary expresses a maintenance window.** The collector requires every
|
||||
writer stopped while it runs. A `run-once` step runs *beside* containers, not instead of them, and
|
||||
a scheduled step is the same container on a cadence. There is no way for a module to say *hold this
|
||||
container of mine still while this runs*.
|
||||
|
||||
**Deletion is not enabled, and the door it would be enabled on has no accounts.** The store is
|
||||
internal, reached by name over the overlay, trusted because being on that network is the permission
|
||||
([ADR 0082](0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md)). The predecessor
|
||||
kept deletion behind an authenticated door, which it could, having one.
|
||||
|
||||
**Nothing says what may be removed.** The registry's own answer — collect everything no tag names —
|
||||
is wrong here. The mesh pushes each artifact under one moving tag and pins machines by digest, so
|
||||
every build but the newest is untagged and some machine may still be running it.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Deletion is enabled on the store's one door, and the overlay stays the permission.** The
|
||||
objection dissolves on inspection: that door **already accepts a push**, and a writer who can push
|
||||
can replace any tag in the store with anything it likes. Delete takes nothing a push did not
|
||||
already have, and the machines that can reach the door are the ones the mesh's own filter admits
|
||||
([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)). Putting an authenticated
|
||||
door in front of deletion while leaving push open would be a lock on the window beside an open
|
||||
door, and it would cost the thing ADR 0082 bought: a store every machine can reach without a
|
||||
credential to distribute first.
|
||||
|
||||
**2. The mesh deletes what it made and no longer keeps; the store reclaims the bytes.** Two halves,
|
||||
each doing what only it can.
|
||||
|
||||
The **mesh** decides. It does not need to enumerate the store to do it — it has never put anything
|
||||
there it did not record, so **every digest it could remove is already in its own build records**.
|
||||
It deletes those manifests through the store's door, by digest, and remembers that it did.
|
||||
|
||||
The **store** reclaims. A deleted manifest frees no bytes until the registry's own collector walks
|
||||
the storage with nothing writing to it, so the module declares that collector as a scheduled step
|
||||
with the server held still for its duration. Plain collection, not `--delete-untagged`: what the
|
||||
mesh keeps is still a manifest in the store, so it is still referenced, so its blobs stay — the
|
||||
dangerous flag is not needed at all once the mesh is the one deciding.
|
||||
|
||||
**3. What the mesh keeps, stated as three reasons rather than a number.** A digest is kept because:
|
||||
|
||||
- **a definition names it** — every artifact reference in any module's current recorded manifest,
|
||||
which is what the mesh would hand a machine now. No age limit: this is the floor;
|
||||
- **the mesh can still go back to it** — every artifact of the **five most recent successful
|
||||
builds** of each module, so a release that turns out wrong has somewhere to return to;
|
||||
- **nothing else.** An artifact older than that, which no definition names, is what the store is
|
||||
carrying for no stated reason.
|
||||
|
||||
A digest the mesh did not record making is never touched. That is not a safety margin, it is the
|
||||
whole rule restated: the mesh removes what it put there and can account for, and the images genesis
|
||||
pushed before any record existed are exactly what this must not reach
|
||||
([04-ISSUES/102](../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md), F4).
|
||||
|
||||
**4. A scheduled step may hold its module's own containers still while it runs** —
|
||||
`while-stopped`, naming resource ids in the same module. The host stops each, runs the step, and
|
||||
starts them again **whatever the step did**, including when it failed or the host was interrupted.
|
||||
Three boundaries:
|
||||
|
||||
- **Its own module's containers only.** A module that could quiesce a neighbour could stop the
|
||||
mesh; a maintenance window is a statement about one service's own insides.
|
||||
- **Scheduled steps only, not `run-once`.** At apply time the host already has a window: the
|
||||
declaration is applied in order and a step gates what follows, so a one-time offline migration
|
||||
says *before* rather than *instead of*. A recurring window is the case order cannot express.
|
||||
- **Restoring is not conditional.** A step that fails must leave the service running; the whole
|
||||
risk of this field is a window that never closes.
|
||||
|
||||
**5. The sweep runs where the records change — after a build the mesh recorded.** That is the
|
||||
moment new bytes landed and the moment the keep set moved, and it needs no new timer. The
|
||||
store's collection runs nightly, because reclaiming is slow and the thing it reclaims is already
|
||||
unreferenced.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Disk stops growing without bound on the machine that serves the mesh. That is the whole point
|
||||
and it has no other way to be true.
|
||||
- A machine behind by more than five builds of a module, which recreates a container, cannot pull
|
||||
what it was running. It is already a machine the mesh reports as behind, and the answer is the
|
||||
one the mesh already gives it: the current declaration. Stated here rather than discovered.
|
||||
- The store is a little less of a museum. A digest in an old build record may no longer be
|
||||
fetchable, and the record still says what that build made — the record is history, not an
|
||||
index of what is on disk. The collected mark is kept beside it so the two can be told apart.
|
||||
- `while-stopped` is a second thing the host does to a container it did not start this pass. It is
|
||||
deliberately the narrowest form: the module's own, by id, restored unconditionally.
|
||||
- The store is briefly unavailable each night, for as long as collection takes. Everything that
|
||||
pulls from it retries; nothing in the mesh treats a momentary store as a failure
|
||||
([ADR 0185](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md)).
|
||||
- **An apply arriving during the window reopens it**, because the host's rule for a container it
|
||||
finds stopped is to replace it, and `while-stopped` is the first thing that makes a stopped
|
||||
container intentional. Found by reading this before it merged, recorded as
|
||||
[issue 224](../04-ISSUES/224-an-apply-reopens-a-maintenance-window-by-recreating-what-it-held-still/00-report.md)
|
||||
rather than fixed here: the two candidate fixes — the window takes the apply lock, or the apply
|
||||
learns which containers are held — are each a decision with its own cost, and neither belongs
|
||||
inside this record. Nothing is worse than it was; the store has never collected at all.
|
||||
- **The sweep is bounded**: at most two hundred artifacts and sixty seconds per build, stopping at
|
||||
the first refusal, because it runs inside somebody's build. What is left over is offered again
|
||||
next time. The store stops growing from the first sweep; it does not empty in one.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- The host: a scheduled step with `while-stopped` stops the named containers before the run and
|
||||
starts them after; it starts them again **when the step fails**; it refuses an id that is not a
|
||||
container of the same module, its own id, and `while-stopped` on a `run-once` step. Each refusal
|
||||
is tested for what it says, not only that it says something.
|
||||
- The controller: given build records and current manifests, the keep set holds every reference a
|
||||
manifest names and every reference of the five most recent builds per module, and nothing else;
|
||||
a reference the mesh never recorded is never in the delete set; a delete that answers 404 is
|
||||
recorded as collected rather than retried forever.
|
||||
- The sweep is tested against a fake store that records what it was asked to delete, so what is
|
||||
asserted is the decision and not the registry's behaviour.
|
||||
- Live: the store's size before and after the first nightly collection, read from the machine.
|
||||
|
||||
## References
|
||||
|
||||
- [issue 108 — the registry has no garbage collection](../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md)
|
||||
- [ADR 0082 — the registry is reached by name and trusted by the overlay](0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md)
|
||||
- [ADR 0053 — a step that runs on a schedule](0053-a-step-that-runs-on-a-schedule.md)
|
||||
- [ADR 0156 — an artifact is what a build produces, and the store is named for its scope](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)
|
||||
- [design 32 — what a module declares](../03-DESIGN/01-to-be/32-what-a-module-declares.md)
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
---
|
||||
topic: the tiers
|
||||
status: accepted
|
||||
date: 2026-10-03
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
|
||||
---
|
||||
|
||||
# 199. A module that answers names declares its zone, and a node's hosts file is one module's
|
||||
|
||||
## Context
|
||||
|
||||
**[ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md) and
|
||||
[ADR 0196](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md) leave
|
||||
one resolver holding the nodes' internal domains, and retire the resolver every node ran.** Two kinds
|
||||
of names lived in those per-node resolvers that are neither a node nor a route, and both were found on
|
||||
the workstation on 2026-10-03:
|
||||
|
||||
- **Names a module answers.** The lab raises scenario machines and gives them addresses from its
|
||||
scenario files — the anchor's stand-in at a documentation address, the home server's on the LAN —
|
||||
and the workstation resolved `<machine>.incus` through two wildcard lines in a drop-in file its
|
||||
resolver read. The lines were written by hand; the addresses are the lab's, known only while a
|
||||
scenario runs.
|
||||
- **The operator's own names, unrelated to the mesh.** Twelve `<loopback> <name>` lines for a
|
||||
client's development hosts, kept in `/etc/hosts` and again in `/etc/hosts.local`, which the per-node
|
||||
resolver read as additional hosts.
|
||||
|
||||
**A manifest never names an address, a node or a domain** ([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)).
|
||||
So the lab cannot list `<machine>.incus → <address>` in its definition, and the operator's twelve lines
|
||||
are not any module's to define.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**For a module's names:**
|
||||
|
||||
1. **The manifest lists its records.** Refused by ADR 0112: the addresses are the lab's runtime facts
|
||||
and the scenario's choice.
|
||||
2. **The module reports its records at runtime to the mesh's resolver**, which writes them into its
|
||||
configuration. It works, and it makes the resolver hold every module's runtime state and decide,
|
||||
per call, whether the caller may write the name it sent — authorisation for a write, on the one
|
||||
server every node depends on.
|
||||
3. **The module declares the zone it answers and the listen that answers it; the mesh's resolver
|
||||
forwards that zone there.** The definition names a zone (from a setting) and one of its own listens,
|
||||
which ADR 0112 allows; the address and the port are the mesh's facts. The records stay where they
|
||||
are known — in the module, at runtime. Chosen.
|
||||
|
||||
**For the operator's names:**
|
||||
|
||||
1. **Records the controller holds, served by the mesh's resolver.** They are not the mesh's: a client's
|
||||
development hosts on one machine are nothing any other node should resolve, and the controller would
|
||||
become the keeper of a workstation's private notes.
|
||||
2. **A node-scoped module owns `/etc/hosts`, and the operator's lines live in its kept region**
|
||||
([ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)), changed
|
||||
through that module's tools on that machine. Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A module that answers names declares a zone.** Its definition names the zone — a single label or a
|
||||
dotted name, from a setting, never a domain the mesh knows — and the listen that answers DNS for it.
|
||||
The controller refuses two modules in the mesh declaring one zone, and a zone that is the mesh's suffix,
|
||||
under it, or one of a node's public domains: a module may not shadow names the mesh or the public DNS
|
||||
answers.
|
||||
|
||||
**2. The mesh's resolver forwards each zone to the module that declared it.** The controller hands the
|
||||
holder of `mesh-dns-resolver` every declared zone with the private address of the node its module runs
|
||||
on and the port that listen is published on; the holder places one forwarding rule per zone into its
|
||||
configuration and answers nothing in that zone itself. What names exist in the zone, and their
|
||||
addresses, are the module's — answered by its own long-running code
|
||||
([ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)),
|
||||
from its own state, as they change. Whether an answered address is reachable from the asking node is
|
||||
the module's matter, not the resolver's.
|
||||
|
||||
**3. A node's `/etc/hosts` is held by one module, through a node seat, `node-hosts-file`.** The seat is
|
||||
the definition: its holder owns `/etc/hosts`, and implements three verbs — MCP tool definitions served
|
||||
as `<node>/node-hosts-file.<verb>` ([ADR 0195](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)):
|
||||
**`entries`** (the file's lines, the module's and the operator's, each marked whose), **`add`** (one
|
||||
address and its names, into the operator's region) and **`remove`** (one name or address from it). The
|
||||
module writes the machine's own lines — loopback and the machine's name — and keeps a region for the
|
||||
operator, which survives every push and is given back when the module goes. Its tools change that
|
||||
region on that machine, escalating as the packet filter's do
|
||||
([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) §4). **The
|
||||
controller holds none of it:** an operator's line is the machine's, not a record.
|
||||
|
||||
**4. No other module writes `/etc/hosts`.** The private network's region goes, as ADR 0194 already has
|
||||
it; a module that once wrote a line there asks the mesh's resolver instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The lab's names follow its scenarios.** A scenario raised is resolvable from every node at once; a
|
||||
scenario torn down is gone, with no line left behind in any file.
|
||||
- **The mesh's resolver holds no module's state.** It holds the nodes' domains and a table of who
|
||||
answers which zone, both composed by the controller; nothing writes to it at runtime.
|
||||
- **A module answering a zone needs a DNS answerer of its own** — a long-running bundle, or a resolver
|
||||
it runs. The lab gains one.
|
||||
- **The operator's names reach the machine's own programs, not its containers.** A container does not
|
||||
read the machine's `/etc/hosts`. For names unrelated to the mesh that is the right boundary; a name a
|
||||
container needs belongs in a zone.
|
||||
- **Taking `/etc/hosts` keeps what is there.** The first time the module writes the file, every line
|
||||
that is not the machine's own goes into the operator's region, so a workstation's twelve lines survive
|
||||
the take — the same adoption [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md) gives
|
||||
every shared file.
|
||||
|
||||
**How each is checked:**
|
||||
|
||||
- **Zones:** the controller's catalogue tests refuse a second module declaring a zone, a zone under the
|
||||
mesh suffix, and a zone equal to a node's public domain.
|
||||
- **Forwarding:** on the holder, the resolver's configuration carries one forwarding rule per declared
|
||||
zone, at the declaring node's private address and published port; asking any node's resolver for a
|
||||
name in the lab's zone while a scenario runs returns the scenario's address.
|
||||
- **The hosts file:** a push leaves the operator's region byte for byte; `add` followed by `entries`
|
||||
shows the line as the operator's; unassigning the module gives the region back.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md),
|
||||
[ADR 0196](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md) —
|
||||
the one resolver and how nodes ask it.
|
||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why a definition names no address.
|
||||
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
|
||||
[ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md) — kept regions and shared files.
|
||||
- [ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md) —
|
||||
where a zone's answerer runs.
|
||||
- [Connectivity §2](../03-DESIGN/01-to-be/08-connectivity.md) and [the seats](../03-DESIGN/01-to-be/26-the-seats.md),
|
||||
amended alongside.
|
||||
- [Research 023](../01-RESEARCH/023-a-seat-protocol-that-defines-what-its-holder-owns/00-overview.md) —
|
||||
the general form of decision 3's "the holder owns `/etc/hosts`".
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
---
|
||||
topic: building it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0067-genesis-is-a-pivot.md
|
||||
---
|
||||
|
||||
# 200. Genesis pivots to the controller as a container, and the first push hands it to a process
|
||||
|
||||
## Context
|
||||
|
||||
The controller is Go, compiled to one static binary, and is the last of the mesh's own programs a
|
||||
machine runs from an image ([issue 213](../04-ISSUES/213-the-controller-is-a-go-program-run-in-a-container/00-report.md)).
|
||||
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
|
||||
§1 says a module's own code is bundles, never an image, and §3 that a service bundle is a `process` the
|
||||
host runs. The handover exists: a process may name the container it `replaces`, and the host removes
|
||||
that container only after the process has stayed up across two checks; two controllers are safe
|
||||
together for that moment, the second standing by on the controller's consumers and every plan held by
|
||||
one lock.
|
||||
|
||||
What stands in the way is genesis ([ADR 0067](0067-genesis-is-a-pivot.md)), which
|
||||
[issue 223](../04-ISSUES/223-a-new-mesh-installs-its-controller-as-a-container/00-report.md) found
|
||||
assumes an image and a container at every step from its third: it builds the controller's image,
|
||||
starts a temporary controller from it, publishes it, finds the controller's container in the pivot
|
||||
declaration, and from then on talks to the controller through it. A process's bundle is fetched from
|
||||
the artifact store, and genesis raises the artifact store only after the pivot.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Raise the artifact store before the pivot**, publish the controller's bundle to it, and talk to
|
||||
the controller from the host's side. Rejected for now: it reorders genesis around a store that is
|
||||
itself a module the controller deploys, and rewrites the steps that talk to the controller — a
|
||||
larger change to the one path that is exercised least, to remove a container that exists for
|
||||
minutes.
|
||||
2. **Pivot to the controller as a container, as today, and let the first push hand it over to the
|
||||
process**, through the handover that already exists. Chosen.
|
||||
3. **Keep the controller a container.** Rejected: it is the exception to ADR 0188 that every other
|
||||
module's code has now left, and it costs a container runtime on the control machine and a
|
||||
container recreation in the middle of a plan.
|
||||
|
||||
## Decision
|
||||
|
||||
**Genesis raises the controller as a container, under the resource the controller's process
|
||||
`replaces`, and the first declaration the controller composes for its own machine hands it over.**
|
||||
The container is genesis's own shape, built from the controller's repository, and is recorded on the
|
||||
control machine exactly as the manifest's `replaces` names it, so the first apply after the pivot
|
||||
finds a replacement for it and removes it once the process is up. The controller's manifest declares
|
||||
only the process; the image form exists for genesis alone and is not a second way to run the
|
||||
controller on a live mesh.
|
||||
|
||||
This is the one bounded exception to ADR 0188 §1: a module's own code in an image, for the minutes
|
||||
between the pivot and the first push, on a mesh being created.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new mesh ends where a running one is: the controller a process, no controller container.
|
||||
- Genesis keeps its steps; what changes is that it no longer reads the controller's container from the
|
||||
manifest, and that it records the container under the name the handover expects.
|
||||
- The controller's repository keeps its image build for genesis and the lab.
|
||||
- The handover is now on genesis's path too: a process that fails to stay up leaves the genesis
|
||||
container serving, and the apply says so — the same rule as on a live mesh.
|
||||
|
||||
## How it is checked
|
||||
|
||||
The installer's test raises a mesh whose controller manifest is the process form, and asserts that the
|
||||
container genesis recorded is exactly what the process `replaces`, so the first apply hands over and
|
||||
leaves one controller. Live, on the running mesh: after the manifest change is pushed, the control
|
||||
machine runs the controller as a process and no controller container, and the controller's seat
|
||||
answers throughout.
|
||||
|
||||
## References
|
||||
|
||||
- [Issue 213](../04-ISSUES/213-the-controller-is-a-go-program-run-in-a-container/00-report.md),
|
||||
[issue 223](../04-ISSUES/223-a-new-mesh-installs-its-controller-as-a-container/00-report.md)
|
||||
- mesh-host#86 (the handover), mesh-controller#252 (two controllers safe together),
|
||||
mesh-controller#253 (the controller's manifest as a process)
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md
|
||||
---
|
||||
|
||||
# 201. A module keeps its current state in key-value buckets it declares, and reaches them through the runtime
|
||||
|
||||
## Context
|
||||
|
||||
A module's code reaches the bus through the node's runtime: it publishes events, subscribes to them
|
||||
and asks tools ([ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)).
|
||||
Events are kept for a week and replayed to a consumer that was away; requests are kept nowhere. What
|
||||
neither gives is **the current value of something**, seen by every machine, including one that joins
|
||||
after it was written. The first module to need it — the operator's agent on a machine — registers MCP
|
||||
servers for every machine as events, and a machine assigned later never hears of them; and it would
|
||||
replay a week of licence rotations where it needs only the binding that holds now. Research
|
||||
[024](../01-RESEARCH/024-state-a-module-keeps-on-the-bus/00-overview.md) measured the alternatives and
|
||||
the grants against a real server.
|
||||
|
||||
[Design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 already names *state* as one of the
|
||||
mesh's relationships — 1:1, last per subject — and reserves it to the mesh's own declarations.
|
||||
[Design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 expects key-value buckets on the bus.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Key-value buckets a module declares, created by the controller, reached through the runtime.**
|
||||
Chosen.
|
||||
2. **State as events on EVENTS, read last-per-subject.** Rejected: retention is per stream and EVENTS
|
||||
keeps seven days, so a value unchanged for a week disappears; a second stream over the same subjects
|
||||
is refused by the server (design 32 §3). And events give no get, list or delete.
|
||||
3. **A last-per-subject stream per module, written by hand.** Rejected: it is what a key-value bucket
|
||||
is on the server, without the client's get, list, delete and watch — the mesh writing NATS's
|
||||
key-value layer again.
|
||||
4. **State in a module's own files or database, shared by asking a tool.** Rejected for state every
|
||||
machine must see: a machine joining later has to know whom to ask and poll, and an owner that is
|
||||
down answers nothing — the property the bus exists to remove.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A module declares its state by name.** `state` names the buckets it owns, by local name; every
|
||||
instance of the module may write and read them. `reads` names another module's bucket as
|
||||
`<module>.<name>`, read-only. A bucket's options are its owner's: how many past values a key keeps,
|
||||
and how long a value lives. A manifest names no bucket, stream or subject (design 32 §1).
|
||||
|
||||
**2. One bucket per module per name, mesh-wide.** A key may name a machine by the module's own
|
||||
convention; the mesh does not scope buckets per machine.
|
||||
|
||||
**3. The controller creates the buckets, from the catalogue, on every raise** — from registration,
|
||||
like a seat's stream, so a reader can watch a bucket whose owner is not yet assigned anywhere. A module
|
||||
never creates one. The runtime's grant on each bucket is the union of what its carried modules may do:
|
||||
an owner's instances write and read, a reader's read.
|
||||
|
||||
**4. Each assignment is issued its buckets in its membership** ([ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)),
|
||||
by the name the module uses for each and whether it may write. The runtime serves `mesh/state.get`,
|
||||
`put`, `delete`, `keys` and `watch` on the bundle's channel from that list, and refuses — with the
|
||||
reason — a bucket the module was not issued and a write to one it only reads. A watch delivers the
|
||||
current values first, without deletions, then an end-of-current marker, then every change, each as a
|
||||
`mesh/state` request the bundle answers.
|
||||
|
||||
**5. No secret is stored in a bucket, sealed or not.** A bucket is a stream, and design 32 §10 keeps
|
||||
every secret off streams. A value that needs a secret names it; the secret travels on request/reply.
|
||||
|
||||
**6. The mesh caps size; a bucket outlives its module.** One value per key and no expiry unless the
|
||||
owner says otherwise; at most 256 KiB a value and 64 MiB a bucket. Unassigning a module leaves its
|
||||
buckets and what is in them ([ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)); a bucket
|
||||
whose declaration is gone from the catalogue is reported, never removed by the mesh.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A machine that joins reads the current state at once, and every machine sees a change as it
|
||||
happens, with no consumer created per reader and nothing replayed.
|
||||
- The runtime's channel has a sixth verb family, and the SDKs a small state surface over it — a
|
||||
contract, which ADR 0039 admits: it changes when the verbs do, rarely, and every module should be
|
||||
rebuilt when it does.
|
||||
- What got harder: the runtime must keep each module to its own buckets, because one principal per
|
||||
machine carries all of them and the server enforces only the union. A write the server refuses
|
||||
surfaces to a client as a timeout, not a refusal, so the runtime's own refusal is what a module sees.
|
||||
- The secrets rule is only partly mechanical. Sealed values cannot be recognised; the runtime refuses
|
||||
a value with a field whose name says it is a credential, which catches the ordinary mistake and not a
|
||||
determined one. For the operator's agent this means an MCP server's authorisation header stays out of
|
||||
its bucket.
|
||||
- Buckets accumulate as modules come and go; that they are reported rather than removed is the price
|
||||
of not deleting data.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A manifest's state names are local, and a read names a bucket its owner declares | the catalogue's registration check, per manifest; a catalogue test that every `reads` whose owner is present names a bucket that owner declares |
|
||||
| Buckets exist for every declared state | the controller's raise asserts them idempotently; its test over a real bus |
|
||||
| Owners write, readers only read | the composer's test of the grants, per principal kind; the runtime's refusal test over a real bus |
|
||||
| A watch hands current values first, without deletions, then changes | the runtime's test over a real bus |
|
||||
| No credential-named field in a value | the runtime's refusal test |
|
||||
| Live | one module puts on one machine and another machine's watch sees it; a machine assigned afterwards reads it at start |
|
||||
|
||||
## References
|
||||
|
||||
- Research [024](../01-RESEARCH/024-state-a-module-keeps-on-the-bus/00-overview.md)
|
||||
- [Design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 and §10, [design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 and §3
|
||||
- [ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md), [ADR 0039](0039-what-the-sdk-holds-and-refuses.md),
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md),
|
||||
[ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
|
||||
---
|
||||
|
||||
# 202. A provider declares what it derives for each consumer, and the mesh tells both ends
|
||||
|
||||
> **Written as 0188 on 2026-10-02, renumbered to 0201, and to 0202 on 2026-10-04.** Twice, for the
|
||||
> same reason twice: the bundles refactor took 0188 while this waited in a pull request, and the
|
||||
> key-value-buckets record took 0201 while this waited again. Both times the number was free when
|
||||
> it was chosen and taken by the time this merged. Only the number moved; the decision is the one
|
||||
> taken on the 2nd. The check that refuses two records sharing a number is what caught it, both
|
||||
> times — a number is how a record is cited, and three repositories cite this one.
|
||||
|
||||
## Context
|
||||
|
||||
An arrangement between a consumer and a provider is delivered entirely by the mesh. Where the
|
||||
provider is, which port it answers on, what name the consumer must present, where its password
|
||||
is — each arrives as a fact the consumer reads from its binding, or as `${bound:…}` filled into a
|
||||
file before the declaration leaves the control plane. The provider invents none of it and hands
|
||||
none of it back ([ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)).
|
||||
|
||||
One kind of value escapes that. Where the **provider names the resource** — a bucket, a database,
|
||||
a vhost — the name is derived from the consumer, per consumer, and the mesh has no way to carry
|
||||
it. `serves` is a literal block in the provider's definition: the same values for every consumer.
|
||||
A provisioner's contract takes a provision and returns nothing. So a value the mesh's own rule
|
||||
produced reaches neither end as a statement; it is recomputed at one end and transcribed at the
|
||||
other.
|
||||
|
||||
The object store is the instance ([issue 124](../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)).
|
||||
Its provisioner normalises the login the mesh minted into a bucket name and creates, checks and
|
||||
removes exactly that; the rule lives in twenty lines of the module's own TypeScript. Its three
|
||||
consumers each write the answer into their own definition by hand. Two transcribed it correctly;
|
||||
one named a predecessor's bucket, and would have authenticated successfully and been refused on
|
||||
every object, which reads like a credential fault and is not one.
|
||||
|
||||
Even corrected, the transcriptions are wrong in a second way. Each is `mesh-<node>-<slug>`, so
|
||||
each **names the machine the module happens to run on today** — a definition stating a fact about
|
||||
one installation, which [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md)
|
||||
forbids and whose check does not catch because the name is not a domain. Move any of the three to
|
||||
another machine and its configuration points at a bucket its key cannot open.
|
||||
|
||||
The shape is not the object store's. A database provisioner that prefixed names, a queue provider
|
||||
that scoped vhosts, any provider that derives a resource from who is asking: each forces the
|
||||
consumer to reproduce somebody else's rule and keep it in agreement by hand.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A served value may name the consumer the mesh is serving.** A `serves` block, which is
|
||||
literal today, may interpolate the mesh's own statement of who the consumer is:
|
||||
|
||||
- `${consumer:as}` — the identity the mesh minted for this consumer, exactly as the login it is
|
||||
told to present ([ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md));
|
||||
- `${consumer:as:dns}` — the same identity written as a DNS label.
|
||||
|
||||
Nothing else. **The mesh learns no protocol here; it spells its own name in an alphabet it already
|
||||
knows.** The identity is the mesh's, minted by the mesh, already capped at twenty characters
|
||||
because of what an S3 access key accepts; `dns` is that same name with its separator written `-`
|
||||
instead of `_`, which is the whole of the difference between the mesh's identifier alphabet and
|
||||
the one buckets, vhosts and hostnames use. A provider that needs a prefix or a suffix writes it
|
||||
around the placeholder, because a served value is a string.
|
||||
|
||||
The rejected alternative is **the provider returning values from provisioning** — the natural
|
||||
channel, since the provider is what derived them. It is rejected for three reasons, in order of
|
||||
weight. It inverts the delivery the mesh is built on: a grant would carry data the provider wrote
|
||||
rather than only data the mesh minted, and [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)
|
||||
removed exactly that second path once already. It makes a consumer's declaration incomplete until
|
||||
its provider's reconcile loop has run, so a consumer could not be composed before a provider
|
||||
answered — a bootstrap order the mesh does not have and does not want. And it puts the rule where
|
||||
nothing can check it: a value that arrives from a running process cannot be refused at resolution,
|
||||
only discovered wrong later, which is the failure this record exists to end.
|
||||
|
||||
**2. The mesh resolves it once, per consumer, and tells both ends from the one resolution.** At the
|
||||
moment a consumer's declaration is composed, the mesh knows exactly who the consumer is. There, and
|
||||
only there, the placeholders are filled. The result reaches:
|
||||
|
||||
- the **consumer**, as the served facts in its binding file and as `${bound:<provision>:<key>}` in
|
||||
any file it writes — unchanged mechanisms, carrying one more key;
|
||||
- the **provider**, as `serves` on that consumer's entry in its contributions file, so the
|
||||
provisioner is *told* the name rather than recomputing it.
|
||||
|
||||
**The provider stops deriving in code and starts declaring.** One statement, filled once, delivered
|
||||
to both ends: the two cannot disagree, because there is no second computation to disagree with.
|
||||
|
||||
**3. A served value stays settled before it is per-consumer.** Settings still compose into `serves`
|
||||
([ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)), and the consumer
|
||||
placeholders are filled after that, so an operator may set a prefix and the mesh still derives the
|
||||
rest. A `${consumer:…}` naming a fact or an alphabet the mesh does not have is refused when the
|
||||
definition is parsed, with what it may say.
|
||||
|
||||
**4. A consumer may no longer name the resource its provider derives.** With the value delivered,
|
||||
a literal in a consumer's definition is not merely redundant — it is the one thing that can
|
||||
disagree with what the provider will actually create. The three object-store consumers lose their
|
||||
hand-written bucket names in this change.
|
||||
|
||||
**5. A consumer that keeps several holders of one provision may not be served a derived value.**
|
||||
Each holder gets its own login, `…_<local>` ([ADR 0094](0094-a-module-may-hold-several-secrets-from-one-provider.md)),
|
||||
and a provider derives from the login — so it would make one resource per holder, while the
|
||||
consumer's side has one binding and one `${bound:<provision>:<key>}`, both derived from the
|
||||
un-suffixed identity. That is this record's own failure one case to the side, and just as quiet:
|
||||
the consumer would authenticate and be refused on every object. Refused at resolution, naming
|
||||
both ends. Lifting it means giving the consumer's side a local dimension, which is a decision and
|
||||
not an omission.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One more thing a definition may say, and one less thing a module may be wrong about. The
|
||||
vocabulary grows by a placeholder; the catalogue loses three literals that named this
|
||||
installation's control node.
|
||||
- A provider's naming rule becomes readable in its definition instead of in its source. `minio`'s
|
||||
`bucketFor` goes; the manifest says `"bucket": "${consumer:as:dns}"` and the provisioner uses
|
||||
what it is given.
|
||||
- A provider that already serves consumers keeps serving them: the derived value equals what the
|
||||
code derived, so no bucket, database or login changes name. This is a change of **who says it**,
|
||||
not of **what is said**.
|
||||
- A refusal here fails **that machine's push**, naming the definition, and nothing else. That is
|
||||
deliberate and is the opposite of a module quietly left out: a definition that transcribes
|
||||
somebody else's rule is wrong everywhere, not just here, and the loud failure is in front of
|
||||
whoever can fix it.
|
||||
- The mesh now holds a rule in another system's alphabet — one rule, `dns`, stated once. A second
|
||||
alphabet is a decision, not an addition: the cost of each is that the mesh must be right about
|
||||
somebody else's naming, and that cost is only worth paying where the mesh already mints the name.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- A served value naming an unknown fact or alphabet is refused at parse, with the list of what it
|
||||
may say — tested on both halves of the message.
|
||||
- Resolving a consumer whose provider derives a value puts that value in the consumer's binding
|
||||
file, in its `${bound:…}` substitutions, and in the provider's contributions entry for that
|
||||
consumer — one test asserting the three agree, because agreeing is the whole point.
|
||||
- Two consumers of one provider on one machine get two different derived values, and neither gets
|
||||
the other's.
|
||||
- A consumer with several holders of a deriving provider is refused, with both ends named — the
|
||||
test asserts the refusal, not merely that something failed.
|
||||
- A catalogue-wide test refuses a consumer definition that writes a literal where its provider
|
||||
derives: the provider's `serves` names the key, so the catalogue can say which definitions
|
||||
transcribe one.
|
||||
- `dns` is checked against the identity the mesh actually mints, not against an invented string:
|
||||
the test derives an identity with `ConsumerIdentity` and asserts the label it becomes.
|
||||
|
||||
## References
|
||||
|
||||
- [issue 124 — a consumer cannot be told a value its provider derived for it](../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)
|
||||
- [ADR 0048 — a provider creates the credential the mesh minted, and seals nothing](0048-a-provider-creates-the-credential-the-mesh-minted.md)
|
||||
- [ADR 0049 — a consumer's identity fits the tightest backend](0049-a-consumers-identity-fits-the-tightest-backend.md)
|
||||
- [ADR 0174 — a node varies a module through settings and kept regions, never through an edit](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
|
||||
- [ADR 0155 — a definition names no installation, and how that is checked](0155-a-definition-names-no-installation-and-how-that-is-checked.md)
|
||||
- [design 27 — a module requires, the mesh resolves](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md)
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
|
||||
---
|
||||
|
||||
# 203. The account's environment is one module's, and every module contributes to it
|
||||
|
||||
## Context
|
||||
|
||||
A variable or a `PATH` entry is a fact about the operator's account. A toolchain needs its directory
|
||||
on `PATH`, a version manager needs a variable naming its directory, an agent needs a variable that
|
||||
turns one of its behaviours off, and the shell sets an editor. Today every one of these is a line of
|
||||
one shell's syntax in one hand-written startup file. [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
|
||||
measured on four machines:
|
||||
|
||||
- about a quarter of the 65 lines a workstation runs at shell start are environment;
|
||||
- written into `.zshrc`, that environment reaches only interactive zsh. It misses the login shell's
|
||||
`execute` verb ([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)),
|
||||
every script, and every program a graphical session starts;
|
||||
- the service manager's place for the account's environment, `~/.config/environment.d/`, holds
|
||||
nothing on any machine.
|
||||
|
||||
The shell module as first written carried some of these lines in its own block and dropped the rest.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Each module writes its own lines into the shell's startup file.** Rejected: one shell's syntax,
|
||||
read by one kind of start, and the same lines rewritten by every shell module.
|
||||
2. **Contribute the facts to the login shell, whose holder renders them.** Rejected: the environment
|
||||
then depends on which module holds the shell, every shell module renders the same facts again, and
|
||||
the graphical session sees nothing.
|
||||
3. **Option 2, and the service manager's holder renders the same facts a second time** into
|
||||
`environment.d`. Rejected: one fact set with two owners, whose renderings can disagree, and a
|
||||
duty for the service manager unrelated to managing services.
|
||||
4. **One file in `environment.d` syntax, sourced by shells.** Rejected: that syntax is close to
|
||||
POSIX assignment but not equal, and a value one reader accepts breaks the other.
|
||||
5. **Shells read the service manager's environment generator.** Rejected: every shell start then
|
||||
runs a process and depends on the service manager, and the output is unquoted.
|
||||
6. **A module of its own holds the environment.** One mesh seat, held by one module per node,
|
||||
whose files are the account's environment. Every module contributes facts to it, and those facts
|
||||
are written in each reader's format. Chosen. It was the operator's proposal.
|
||||
|
||||
Within option 6, two ways to write the files:
|
||||
|
||||
- **a. The holder's own code renders what it receives.** This was research 025's starting position.
|
||||
Rejected: the code needs something to run it whenever a contribution changes, and the result exists
|
||||
only after a machine has applied and run it.
|
||||
- **b. The controller renders the facts into the holder's files,** in two named formats, at
|
||||
composition. Chosen. The result is in the declaration before any machine applies it, nothing has to
|
||||
trigger anything, and the two formats are standards: POSIX shell assignment and the service
|
||||
manager's `environment.d`. The controller learns no shell. It writes an assignment in a standard
|
||||
syntax, as it already writes a fail2ban stanza a module supplied.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. The environment is a node seat, `node-environment`, in the mesh's own set.** One module per node
|
||||
holds it, and it is the only writer of the account's environment. The first holder is a module of its
|
||||
own (working name `node-env`), with no package and no process.
|
||||
|
||||
**2. Any module contributes to it with `environment`:**
|
||||
|
||||
- **`variables`:** names and values. A name is a POSIX variable name and never `PATH`. A value is
|
||||
literal; it may use `${machine:…}`, resolved first, and may not contain `$`, a quote, a backslash or
|
||||
a line break. The only expansion is the mesh's own, so the two formats cannot read one value
|
||||
differently.
|
||||
- **`path`:** entries, each placed at the `start` or the `end` of the account's `PATH`.
|
||||
|
||||
**3. The holder places the rendered environment with two placeholders** in its own files:
|
||||
|
||||
- **`${environment:posix}`** renders lines a POSIX shell sources:
|
||||
- every variable exported;
|
||||
- every `PATH` entry added only if missing, so sourcing twice changes nothing.
|
||||
- **`${environment:systemd}`** renders the same facts as the service manager's user environment, with
|
||||
the account's existing `PATH` kept between the start and the end entries.
|
||||
|
||||
Each rendered line names the module that contributed it, so the file answers *where did this come
|
||||
from*. Contributions are ordered by module name, and then in the order a module declared them.
|
||||
|
||||
**4. The seat's protocol fixes where the POSIX file is:** `~/.config/mesh/environment.sh` under the
|
||||
account's home. A shell sources that path without knowing which module wrote it. The service
|
||||
manager's file is `~/.config/environment.d/50-mesh.conf`.
|
||||
|
||||
**5. Refused at composition:**
|
||||
|
||||
- two modules on one node setting the same variable, both named;
|
||||
- an environment placeholder in a module that does not claim `node-environment`.
|
||||
|
||||
A node with contributions and no holder writes them nowhere. The holder's absence is visible in the
|
||||
node's assignments, and no contributor is refused for it, because a missing `PATH` entry is a gap, not
|
||||
a broken machine.
|
||||
|
||||
> **The mechanism changed — 2026-10-04, by [ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md).** A contribution is now a dependency on
|
||||
> node-environment, met and refused as ADR 0207 says, so a node without the holder refuses the
|
||||
> contributor instead of writing the contribution nowhere. The rest of this section, and the
|
||||
> decision, stand.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A shell's part in the environment is one line in its always-read startup file, sourcing the
|
||||
POSIX file. A second shell module writes the same line in its own syntax, and no contributor
|
||||
changes when the login shell does.
|
||||
- The graphical session sees the same `PATH` as the terminal, from the same facts.
|
||||
- The controller gains one gathered field and two renderers. Both are tested byte for byte, like
|
||||
the jails a node composes ([to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)).
|
||||
- What a person sets for themselves stays theirs: variables of their own sit in their own lines of
|
||||
their shell's file, read after the mesh's.
|
||||
- **What got harder:** a value that needs another variable expanded (`$HOME`, `$XDG_CONFIG_HOME`)
|
||||
must be written with the mesh's own `${machine:…}` facts, or it is refused. Expansion at shell start
|
||||
is exactly what made one value mean two things in two readers.
|
||||
- Once [issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)
|
||||
closes, a value a person varies becomes a setting of the module that contributes it
|
||||
([ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Both renderings, byte for byte, from a fixed set of contributions | the controller's environment tests |
|
||||
| Sourcing the POSIX rendering twice leaves `PATH` unchanged | the same tests, running `sh` over the rendering |
|
||||
| A variable set by two modules is refused, naming both | the controller's resolve test |
|
||||
| An environment placeholder outside the holder is refused | the catalogue check, which registration runs |
|
||||
| A value with `$`, a quote, a backslash or a line break is refused | the manifest's parse test |
|
||||
| The zsh holder sources the path the seat fixes | the catalogue's zsh test |
|
||||
|
||||
## References
|
||||
|
||||
- [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
|
||||
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
|
||||
[ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md),
|
||||
[ADR 0177](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md),
|
||||
[ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
|
||||
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
|
||||
---
|
||||
|
||||
# 204. A module contributes shell code to the login shell in named slots, and the login shell is the mesh's seat
|
||||
|
||||
## Context
|
||||
|
||||
Some of what a shell runs at start is code in that shell's own syntax, and it belongs to other
|
||||
modules:
|
||||
|
||||
- a prompt theme loads itself and its configuration;
|
||||
- plugins load themselves;
|
||||
- a version manager sources its loader.
|
||||
|
||||
Order matters: a prompt's instant-prompt cache must run first, and syntax highlighting last. The
|
||||
predecessor kept all of this in one file per machine, and installed the theme and plugins by cloning
|
||||
them in a hook.
|
||||
[Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md) measured that file
|
||||
as byte-identical on four machines. It carries:
|
||||
|
||||
- the shell's defaults;
|
||||
- code belonging to four other pieces of software;
|
||||
- a handful of the operator's own lines.
|
||||
|
||||
Nothing gave the other pieces a way in.
|
||||
|
||||
Two further facts bear on the seat itself:
|
||||
|
||||
- **The seat is declared by the zsh module** ([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
|
||||
§1, under [ADR 0126](0126-a-module-declares-its-own-seats.md)). The controller refuses a second
|
||||
module declaring a seat name, so fish or bash could only ever claim it, and the seat exists only
|
||||
while zsh's definition is registered.
|
||||
- **The kept region is the other way round.** [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
|
||||
describes a kept region as a marked block holding the operator's lines. The host built the inverse:
|
||||
the mesh's region is the marked block, and every byte outside it is kept, verified unchanged, and
|
||||
given back when the module goes.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **A drop-in directory** that each module places a file in, and the shell sources. Rejected: every
|
||||
contributor names a path inside the shell module's territory
|
||||
([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)); order becomes a naming
|
||||
convention nothing checks; and nothing ties the file to the shell actually being the one it is
|
||||
written for.
|
||||
2. **Facts the holder renders**, through `contributes` / `receives`. Rejected: code is not a fact,
|
||||
and the holder would only paste it.
|
||||
3. **Code contributed for a named shell in a named slot, assembled by the controller into the
|
||||
holder's file.** This is what the controller already does for fail2ban jails: each module supplies
|
||||
text in the tool's own format, and the controller sorts and concatenates it into the holder's file
|
||||
without interpreting it. Chosen.
|
||||
|
||||
For order, numbers (`10`, `50`, `90`) were rejected: every contributor guesses one, and collisions are
|
||||
silent. **Three named slots** were chosen: `first`, `normal`, `last`.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. `node-login-shell` is a node seat in the mesh's own set,** with the verb `execute`. It replaces
|
||||
the module-declared `login-shell`. Everything else ADR 0176 decided stands: the holder sets the
|
||||
account's login shell through the `user` shape, `execute` is the contract, and any node may call it.
|
||||
A shell module claims the seat; none declares it.
|
||||
|
||||
**2. Any module contributes shell code with `shell`:** entries naming the shell they are for (`zsh`,
|
||||
`bash`, `fish`), the slot, and the code. The controller does not read the code.
|
||||
|
||||
**3. The holder places the code with placeholders** in its own files: `${shell:<shell>:<slot>}`. Each
|
||||
is filled with that shell's code for that slot, from every module on the node:
|
||||
|
||||
- ordered by module name;
|
||||
- each piece preceded by a line naming its module;
|
||||
- empty when nothing is contributed.
|
||||
|
||||
A shell-code placeholder in a module that does not claim `node-login-shell` is refused.
|
||||
|
||||
**4. The holder's duties, which are the seat's protocol:**
|
||||
|
||||
- Source the account's environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md))
|
||||
from the startup file every start of that shell reads. For zsh that is `.zshenv`, which a script, a
|
||||
login and `execute` all read.
|
||||
- Write its interactive block at the **start** of the interactive startup file, so the operator's own
|
||||
lines run after the mesh's and win.
|
||||
- Run `execute` as a non-interactive login shell in the account's home:
|
||||
- bounded below the runtime's call limit;
|
||||
- its output bounded;
|
||||
- its whole process group ended on timeout.
|
||||
|
||||
**5. The marked block is the mesh's; everything outside it is the operator's.** This is how ADR 0174's
|
||||
"kept region" is built. That record keeps its decision and gains a note saying where the mechanism
|
||||
lives.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A prompt, a plugin and a version manager are each a module with its own package or archive, its own
|
||||
configuration file, and a contribution. Assigning one adds its line to the shell, and unassigning it
|
||||
takes the line away at the next composition.
|
||||
- Assigning the shell module loses nothing the machine does today:
|
||||
- what is common to every machine becomes the shell module's default or another module's
|
||||
contribution;
|
||||
- what is the operator's stays below the block.
|
||||
- **The one-off migration is a person's act**, listed in the shell module's documentation
|
||||
([ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)):
|
||||
delete the lines the block now carries from the found file.
|
||||
- `login-shell.execute` becomes `node-login-shell.execute`. Nothing has called it yet; the shell
|
||||
module was never assigned.
|
||||
- **What got harder:** a module wanting a line in the shell must say which shell and which slot, and a
|
||||
module supporting three shells writes its code three times. That is the honest cost of code in
|
||||
three syntaxes.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Code lands in its slot, in module order, only for its shell | the controller's shell-contribution tests |
|
||||
| A shell-code placeholder outside the holder is refused | the catalogue check |
|
||||
| `node-login-shell` is the mesh's, and no module may declare it | the seat table's tests |
|
||||
| The zsh block sits at the start, sources the environment from `.zshenv`, and holds the three slots | the catalogue's zsh test |
|
||||
| `execute` is bounded in time and output and kills its process group | the zsh module's tool tests over real child processes |
|
||||
|
||||
## References
|
||||
|
||||
- [Research 025](../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md)
|
||||
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
|
||||
[ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md),
|
||||
[ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
|
||||
[to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
|
||||
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
---
|
||||
|
||||
# 205. Software the distribution does not package ships as a pinned archive of the module's own
|
||||
|
||||
## Context
|
||||
|
||||
The prompt theme the operator uses is not in the distribution's repositories. Its two plugins and an
|
||||
autocomplete plugin are. The predecessor installed all four by running `git clone` against their
|
||||
upstream repositories from an install hook. That way:
|
||||
|
||||
- the version on a machine was whatever upstream's default branch held the day the hook ran;
|
||||
- two machines set up a week apart could differ;
|
||||
- a machine with no route to upstream failed its install.
|
||||
|
||||
The mesh already has a pinned, delivered form for a module's own files: an **archive artifact** built
|
||||
from a directory of the module's source, delivered by the artifact store, unpacked by the host's
|
||||
`archive` resource, and pinned by digest. One showcase module uses it.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Clone from upstream on the machine,** as the predecessor did. Rejected: unpinned, unreproducible,
|
||||
and it needs upstream reachable from every machine.
|
||||
2. **Build from the distribution's user repository.** Rejected: the host installs packages from the
|
||||
distribution's own repositories. A user-repository build is a toolchain on every machine for one
|
||||
theme.
|
||||
3. **Vendor a pinned upstream release into the module's directory and ship it as the module's archive
|
||||
artifact.** Chosen. The release and its version are named in the module, its licence travels with
|
||||
it, and every machine gets the same bytes from the mesh's own store.
|
||||
|
||||
## Decision
|
||||
|
||||
**A module whose software the distribution does not package carries a pinned upstream release in its
|
||||
own source directory and ships it as an archive artifact.**
|
||||
|
||||
- The module's documentation names the upstream, the version and the licence.
|
||||
- The host unpacks it with the `archive` resource into a directory the module owns.
|
||||
- An upgrade is a change to the module, reviewed like any other.
|
||||
|
||||
Software the distribution *does* package is installed as a package; a vendored copy of it is
|
||||
refused in review.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalogue grows by the size of what it vendors: 1.4 MB for the prompt theme at the pinned
|
||||
release.
|
||||
- Upstream's security fixes reach a machine only when somebody updates the module. That is the same
|
||||
trade every pinned dependency makes, and it is visible: the version is in the module.
|
||||
- **What got harder:** a vendored program that downloads more at run time, as the prompt theme does
|
||||
for its git status helper, still fetches that part from upstream on first use. This record pins
|
||||
what the mesh ships, not what the software fetches for itself. The module's documentation says so.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The archive is pinned by digest | the host's declaration validation, which refuses an archive without one |
|
||||
| The upstream, version and licence are named | review of the module's documentation; the module's test asserts the licence file is in the archive |
|
||||
| Packaged software is not vendored | review |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
|
||||
- [To-be 41](../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||
---
|
||||
|
||||
# 206. A node reports the Anthropic grant it holds; the licence manager adopts a licence by refreshing it, and what each node should hold is the manager's state
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
|
||||
made the licence manager a module holding the `anthropic-licence-manager` seat: one rotation source, the
|
||||
long-lived grants in its own store, a short-lived token handed to a node sealed on request/reply, the
|
||||
agent module alone writing what the agent reads. How the manager *learns* a licence, and who starts each
|
||||
exchange, it left to a later shape, and three texts have since disagreed: ADR 0183 has a node register
|
||||
its key and the manager adopt a login only into a licence the node is already bound to; its dated note
|
||||
of 2026-10-03 has the manager start every exchange and visit every node on a schedule; the agent module
|
||||
as built asks the seat for its token when an event says to, and pushes a login to the seat.
|
||||
|
||||
**The operator settled it on 2026-10-04, in the operator's own words:** the manager must hold the active refresh token;
|
||||
whichever node a login happened on holds the latest one; every client publishes what its credentials
|
||||
file holds, the manager sees a licence it does not own yet and takes it into its store, and from then on
|
||||
rotates it and distributes the access token. A manager launched for the first time holds no licence and
|
||||
accepts what the clients report. Several nodes report the same account — today the nodes are all logged in
|
||||
to one personal account — and before the manager adopts a grant it must know the refresh token still
|
||||
works.
|
||||
|
||||
Two facts bound how that is built:
|
||||
|
||||
- **A refresh token cannot be published.** Anything published on the bus is kept, and a secret never
|
||||
enters a stream, sealed or not ([design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10).
|
||||
A module's state is a stream too, and the runtime refuses a value carrying a field named like a
|
||||
credential ([ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md);
|
||||
refused live on 2026-10-04 for an `Authorization` header).
|
||||
- **A refresh token can only be checked by using it.** No endpoint answers "is this refresh token
|
||||
valid" without exchanging it, and an exchange is presumed to rotate it (ADR 0183: the predecessor
|
||||
lost a licence to a reused one). Checking and adopting are therefore one act, and whoever checks
|
||||
becomes the token's only live holder.
|
||||
|
||||
Since ADR 0201 the bus has the shape this needs: **state** every node sees, including one that joins
|
||||
later or a manager that starts later, read whole on start and then watched.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Each node publishes its credentials file, the token included.** What the operator described,
|
||||
literally. Rejected for the token only: it would sit in a stream every principal that reads the
|
||||
bucket can read, for as long as the bucket keeps it, and the runtime refuses it anyway.
|
||||
2. **The manager visits every node on a schedule and collects a waiting login** (ADR 0183's dated
|
||||
note). Rejected: the manager must know every node in advance and poll it, a node that joins later
|
||||
waits for the next visit, and "what does each node hold" lives nowhere anyone can read.
|
||||
3. **Each node reports what it holds as state, without the secret; the manager asks for the secret
|
||||
only when the report shows a grant it does not hold, and adopts by refreshing.** Chosen: the
|
||||
operator's flow, with the one part that cannot be on the bus moved onto request/reply.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Every agent module reports what its node holds, as its own state.** One key per node in the
|
||||
module's `holdings` state: the account's identity as the agent's own state file names it (account id,
|
||||
address, organisation), the kind, the refresh token's **fingerprint** and whether one is present at
|
||||
all, the access token's fingerprint and expiry, the licence it was last handed, and when the credentials
|
||||
file last changed. Written when the module starts — a node already logged in when the module is first
|
||||
assigned reports at once — and again whenever the credentials file changes. **No token, ever**: a
|
||||
fingerprint names a token without being one.
|
||||
|
||||
**2. A licence is an account, and the manager learns it from the reports.** The manager reads every
|
||||
node's `holdings` at start and watches them. A report carrying a refresh token whose fingerprint the
|
||||
manager does not hold is a **candidate**: for an account it has no licence for yet, a new licence; for
|
||||
one it has, a login made since. A manager launched for the first time holds no licence and treats
|
||||
every report as a candidate. An API key still enters only through the seat's `adopt` verb, from a file
|
||||
on the manager's node.
|
||||
|
||||
**3. The secret travels only when asked for.** For a candidate, the manager calls that node's agent
|
||||
module on request/reply, giving its own public key, and is answered with the grant sealed to that key
|
||||
(ADR 0183's channel, unchanged).
|
||||
|
||||
**4. Adopting is refreshing.** The manager exchanges the candidate's refresh token at the vendor's
|
||||
endpoint under its lease for that account. If the exchange succeeds, the grant it got back is the
|
||||
licence's, stored encrypted, and the manager is from then on its only rotation source. If it fails, the
|
||||
candidate is recorded dead, nothing is adopted, and the report says so. **Several nodes, one account:**
|
||||
candidates for one account are tried newest login first; the first that refreshes is adopted, and the
|
||||
manager does not exchange the others.
|
||||
|
||||
**5. A node holds an access token only, so the latest login wins.** A node bound to an adopted licence
|
||||
is handed the access token and nothing else, and the agent module writes the credentials file without a
|
||||
refresh token — so the agent on the node can never refresh it, and two refreshers never hold one grant.
|
||||
A refresh token appearing in a node's file afterwards can therefore only be a person's login there; its
|
||||
report makes it a candidate, and if it refreshes it replaces the licence's grant. That is the operator's
|
||||
"whichever node a login happened on holds the latest one", made mechanical.
|
||||
|
||||
**6. What each consumer should hold is the manager's state.** One key per consumer in the manager's
|
||||
`bindings` state: the licence, its kind, and a **generation** that increases with every rotation and
|
||||
every switch. The agent module watches its own key; when the generation is newer than the one it
|
||||
applied, it asks the seat's `current` verb for the token, sending its public key, and is answered sealed
|
||||
(request/reply). A node that was away reads its key when it is back and asks once. The `licence.rotated`
|
||||
and `licence.switched` events go: what they announced is now the state itself, and a node needs the
|
||||
latest, not the history.
|
||||
|
||||
**7. A first binding follows the login.** When the manager adopts a licence from a node's report, a
|
||||
node with no binding yet whose report names that account is bound to it. Every later change is a
|
||||
person's act through `bind`, `switch` and `release`, as ADR 0183 says.
|
||||
|
||||
**8. The identity guard stands, on two sources.** The account a grant is filed under is the identity
|
||||
the node read from the agent's own state. Where the vendor's answer to the refresh names the account,
|
||||
the manager compares the two and refuses a mismatch with a notification; whether it names it is
|
||||
measured when the manager is built, and the record of which source decided is kept in the audit.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The manager needs no configuration to start: launched on a mesh whose nodes are logged in, it adopts
|
||||
every account they hold, one licence each, from the newest login that still refreshes.
|
||||
- Every node's holding is readable by anyone allowed to read the state — the console, an agent, the
|
||||
operator — without a token in sight, which is what `licence_status` on each node answered one at a
|
||||
time.
|
||||
- **What got harder:** adoption consumes the refresh token the node held. On a node whose grant was
|
||||
adopted, the agent's own copy is dead from that moment; until the manager hands it an access token
|
||||
(decision 6), the agent keeps the access token it already had, which lives hours. And a node whose
|
||||
file still holds a refresh token after adoption — it was not handed one yet — is a second holder of a
|
||||
dead grant, not a live one, so the rotation-source rule holds.
|
||||
- A candidate whose refresh fails is not retried by the manager: a dead refresh token does not come
|
||||
back. A person logs in again, and the new report is a new candidate.
|
||||
- Nothing in the reports is secret, but they do say which account each node uses; readers of the state
|
||||
are declared in manifests like any other.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| No report carries a token | the runtime refuses a credential-named field (ADR 0201's test); the agent module's test: a report built from a full credentials file holds fingerprints and identity only |
|
||||
| A node already logged in reports at start | the agent module's test: with a credentials file present and unchanged, starting writes its `holdings` key |
|
||||
| A candidate is adopted only by a successful refresh, newest login first, once per account | the manager's tests against a stub vendor: two reports for one account, the newer refreshes and is adopted, the older is never exchanged; a failing refresh adopts nothing and records the candidate dead |
|
||||
| A node is handed an access token only | the agent module's test: the file it writes after a hand-over holds no refresh token |
|
||||
| A newer generation is fetched once, by request | the agent module's test: a `bindings` change with a newer generation asks `current` once; an equal one asks nothing |
|
||||
| No event carries a token, and none announces a rotation any more | the manager's test of everything it publishes |
|
||||
| Live | the manager launched with no licence on a mesh whose four nodes are logged in to one account adopts one licence, binds the four nodes, and each node's file then holds an access token and no refresh token |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the manager, its seat and its channel, which this extends
|
||||
- [ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md) — module state, and the refusal of a secret in it
|
||||
- [design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10 — no secret in a stream
|
||||
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md), [to-be 39](../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md) — the two modules, amended by this record
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
|
||||
---
|
||||
|
||||
# 207. A module depends on the node seats that apply its resources
|
||||
|
||||
## Context
|
||||
|
||||
A module declares resources: packages, services, containers, files. Some of those are applied
|
||||
through software on the machine that is itself a module:
|
||||
|
||||
- a service through the service manager;
|
||||
- a package through the package manager;
|
||||
- a container through the container runtime.
|
||||
|
||||
Until now nothing said so. A module carried a *capability* such as `service-manager` or
|
||||
`package-manager`, which the host detects on the machine. A capability says the software is
|
||||
installed. It does not say that a module of the mesh holds the role and answers for it.
|
||||
|
||||
The cost showed on 2026-10-04:
|
||||
|
||||
- **A networking module declared the service manager's own package.** The controller allows one
|
||||
declaration of a resource per node, so the module that *is* the service manager could not declare
|
||||
its package and had been written without it. The networking module's real relation to the service
|
||||
manager, that it needs one held on its node, was nowhere.
|
||||
- **The container runtime** has had this decided for its own case since ADR 0165 and ADR 0166
|
||||
(proposed): a module that delivers a container needs the runtime seat held on its machine, derived
|
||||
from the container resource, with no manifest field.
|
||||
- **The operator's order for building the machines' modules** (to-be 42) is *the most core first*.
|
||||
That is an order the mesh should enforce, not one a person should remember.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep capabilities as the only gate.** Rejected: a capability is a fact about the machine, not
|
||||
about the mesh. Software installed by hand satisfies it, and nothing then answers for it.
|
||||
2. **A manifest field per module naming the seats it needs.** Rejected: a module would restate what
|
||||
its resources already say, and a module that adds a service but forgets the field passes.
|
||||
3. **Derive the dependency from the resources,** as ADR 0165 already does for containers, and refuse
|
||||
an assignment whose seats are not held on the node. Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Three node seats apply resources,** each in the mesh's own set:
|
||||
|
||||
| resource | applied through | seat | first holder |
|
||||
|---|---|---|---|
|
||||
| `service` | the service manager | `node-service-manager` (ADR 0177) | `systemd` |
|
||||
| `package` | the package manager | `node-package-manager` (new) | `pacman` |
|
||||
| `container` | the container runtime | `node-container-runtime` (ADR 0166) | `docker` |
|
||||
|
||||
`node-package-manager` is new and has no verbs yet. `node-container-runtime` is seeded now as ADR 0166
|
||||
names it. Its verbs, and the host creating containers through its holder, stay with that record's
|
||||
acceptance.
|
||||
|
||||
**2. A module depends on each seat its resources need.** The controller derives this from the
|
||||
resource types the module declares. A module never states it.
|
||||
|
||||
**3. A dependency is met when any module assigned to the same node holds the seat,** the module
|
||||
itself included. The holders of these seats declare resources of each other's kinds: the service
|
||||
manager's package needs the package manager, and the package manager's timer needs the service
|
||||
manager. They are therefore judged as the node's whole set of assignments, never one at a time.
|
||||
|
||||
**4. Where it is checked:**
|
||||
|
||||
- **At `assign`,** an assignment whose dependencies are unmet by the node's assignments, including the
|
||||
new one, is refused. The refusal names each seat and the modules in the catalogue that can hold it.
|
||||
- **At composition,** an unmet dependency on a node is **reported** in `status` until the three holders
|
||||
are assigned to every node. Then it is **refused** like any unresolved requirement. The switch is one
|
||||
line in the controller, made when `status` reports none.
|
||||
|
||||
**5. The mesh's own foundation is exempt.** These are the pieces genesis lays before any module exists:
|
||||
the host, the private network and the bootstrap runtime. Their declarations are the installation's,
|
||||
not a module's.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The order of to-be 42 becomes the mesh's: `systemd`, `pacman` and `docker` on a node before
|
||||
anything that installs, runs or contains.
|
||||
- **Two modules no longer declare one shared package to say they need it.** A component's module
|
||||
(networkd's) declares what it configures and depends on the seat. The component's own package
|
||||
belongs to the module that holds the seat.
|
||||
- Capabilities stay what they are, facts about the machine, used where a module needs the machine to
|
||||
be able to do something.
|
||||
- **What got harder:** a module can no longer be tried on a node that lacks the core three. That is
|
||||
the point.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The dependency is derived from resources: a service, a package and a container each need their seat | the controller's resolve tests |
|
||||
| A node whose assignments hold the seats passes; one missing a holder is refused at `assign`, naming the seat and its possible holders | the same tests, and `assign` live |
|
||||
| Mutual dependencies among the holders resolve when they are assigned together | the same tests |
|
||||
| Until the switch, an unmet dependency is reported in `status` and does not refuse a push | the controller's status test |
|
||||
| Foundation declarations are exempt | the composition test with genesis's declarations |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md),
|
||||
[ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md),
|
||||
[ADR 0177](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)
|
||||
- [To-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
|
||||
---
|
||||
|
||||
# 208. The graphical session is one module per piece, on the mesh's seats
|
||||
|
||||
## Context
|
||||
|
||||
The two workstations run one predecessor desktop
|
||||
([research 026](../01-RESEARCH/026-the-graphical-session-as-modules/00-overview.md)). It was a module of
|
||||
983 lines with four flavors and 92 theme variables. One of its machines carried another machine model's
|
||||
hardware files, and its session's environment was a hand-kept copy of the account's. The operator asked
|
||||
for the desktop as modules at the shell's level, consistent across machines, with sway as a sibling of i3.
|
||||
To-be 38 named this WP7, and to-be 37 left one question for the resolver: how a module says it needs a
|
||||
display server held on its node.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **One desktop module, as before** (research 026 §1, G1). Rejected: flavors again, and the evidence is
|
||||
a flavor on the wrong machine.
|
||||
- **One module per piece of software.** Chosen.
|
||||
- **For "i3 needs an X server" (research 026 §3):**
|
||||
- a new field (R2), rejected as a second way to say what provisions already say;
|
||||
- assigning carefully (R3), rejected because that is the misassignment the evidence shows;
|
||||
- **a provision with the machine's reach** (R1), chosen.
|
||||
- **For other modules' lines in a holder's file (research 026 §5):**
|
||||
- only drop-ins (C1), rejected because two files the session needs have no drop-in convention;
|
||||
- only slots (C2), rejected as needless where the tool already reads a directory;
|
||||
- **both, the boundary drawn by the tool** (C3), chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. One module per piece of software:** `lemurs`, `xorg`, `i3`, `xterm`, `picom`, `rofi`, `dmenu`,
|
||||
`dunst`, the lock screen, `xclip`, the clipboard manager, `feh`, `i3status-rust`, a theme module,
|
||||
`fonts`, `gnome-keyring`, and later `sway`, `foot`, `waybar` and `mako`. No flavors. What follows a
|
||||
machine's hardware is that model's hardware module (research 027/03).
|
||||
|
||||
**2. The roles are node seats in the mesh's own set,** each with the verbs research 026/05 starts it with:
|
||||
|
||||
| seat | holders | verbs to start with |
|
||||
|---|---|---|
|
||||
| `node-login-manager` | lemurs | `sessions` |
|
||||
| `node-display-server` | xorg, sway | `displays`, `layout` |
|
||||
| `node-display-session` | i3, sway | `reload`, `workspaces`, `windows` |
|
||||
| `node-terminal-emulator` | xterm, foot | `open` |
|
||||
| `node-launcher` | rofi, dmenu | `menu`, the dmenu-compatible command |
|
||||
| `node-notifier` | dunst, mako | `send`, `history` |
|
||||
| `node-lock-screen` | the lock module, swaylock | `lock` |
|
||||
| `node-clipboard` | the clipboard manager | `history`, `copy` |
|
||||
| `node-bar`, `node-compositor` | i3status-rust, waybar; picom | none yet |
|
||||
| `node-secret-service` | gnome-keyring | none yet |
|
||||
|
||||
A compositor that is its own server holds two seats, as sway does.
|
||||
|
||||
**3. A display is a provision with the machine's reach.**
|
||||
|
||||
- A display server provides `x11-display` or `wayland-display`, reachable only on its own machine.
|
||||
- A module that draws on a display requires the one it speaks: i3, picom, xterm and the X lock require
|
||||
`x11-display`; sway's companions require `wayland-display`.
|
||||
- A requirement with the machine's reach is resolved on the requiring module's own node, or not at
|
||||
all, and is refused naming the seat's holders.
|
||||
- `xwayland`, as its own module, provides `x11-display` inside a Wayland session.
|
||||
|
||||
This answers to-be 37 §4 by reusing provisions and reach rather than a new field. A capability the host
|
||||
reports, `graphical-session`, still gates the display server itself.
|
||||
|
||||
**4. Other modules contribute to a holder's file in the tool's own grain.**
|
||||
|
||||
- Where the tool reads a directory, the contributor places its own file there:
|
||||
- i3's `include` directory;
|
||||
- dunst's `dunstrc.d`;
|
||||
- XDG autostart;
|
||||
- `environment.d`;
|
||||
- fontconfig's `conf.d`;
|
||||
- ssh's `config.d`;
|
||||
- the login manager's session directory.
|
||||
- Where it does not, **ADR 0204's slots serve beyond shells.** A `shell` contribution's `for` may also
|
||||
name:
|
||||
- `xinitrc`: POSIX code the session's start runs;
|
||||
- `xresources`: X resources merged at session start.
|
||||
|
||||
The holder of `node-display-server` places them with `${shell:xinitrc:<slot>}` and
|
||||
`${shell:xresources:<slot>}`.
|
||||
|
||||
> **The mechanism changed — 2026-10-04, by [ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md).** A contributor no longer places its own
|
||||
> file in another tool's directory. It contributes to the tool's seat, and the seat's holder places it,
|
||||
> in that directory or through a placeholder. Every contribution, the `xinitrc` and `xresources` slots
|
||||
> included, is a dependency on the seat that receives it. What a contribution contains still follows
|
||||
> the tool's grain.
|
||||
|
||||
**5. The display server's module writes the session's start.** It writes a block at the start of
|
||||
`~/.xinitrc`, in this order:
|
||||
|
||||
1. it sources the account's environment (ADR 0203);
|
||||
2. it imports the session's own variables into the user manager and D-Bus activation, by an explicit
|
||||
list;
|
||||
3. it merges the X resources;
|
||||
4. it runs the `xinitrc` slots;
|
||||
5. it ends by starting the session holder's command, which the session module contributes in the
|
||||
`last` slot.
|
||||
|
||||
The desktop's identity and the theme variables are environment contributions of the session and
|
||||
theme modules. The hand-kept environment in today's file goes, and so does the predecessor's file of
|
||||
secrets (research 027 question 2).
|
||||
|
||||
**6. Per-machine values:**
|
||||
|
||||
- Monitor layouts are profiles keyed by the monitors' identities (research 026/04). They are the
|
||||
operator's data, and the display server's `layout` verb manages them.
|
||||
- DPI and theme values are module defaults now, and settings after issue 168.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A workstation's desktop is a list of assignments, the same on both. The one machine model's
|
||||
hardware is one more assignment.
|
||||
- The X stack is built first, and sway is designed in from the start.
|
||||
- The controller learns:
|
||||
- the eleven seats;
|
||||
- provisions with the machine's reach;
|
||||
- two more names for a contribution's `for`.
|
||||
- **What got harder:** a module that draws must say which display it speaks, and one that wants both
|
||||
ships twice.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The seats are in the mesh's own set, refused to any module that declares them | the seat table's tests |
|
||||
| A machine-reach requirement resolves on its own node only, refused naming the holders | the controller's resolve tests |
|
||||
| `xinitrc` and `xresources` slots are placed only by the display server's holder | the catalogue check |
|
||||
| The session block sources the environment, merges resources, runs the slots and ends with the session | the `xorg` module's manifest test, and on the proving workstation |
|
||||
|
||||
## References
|
||||
|
||||
- [Research 026](../01-RESEARCH/026-the-graphical-session-as-modules/00-overview.md), its 02, 04 and 05
|
||||
- [ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
|
||||
[ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
|
||||
[ADR 0207](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)
|
||||
- [To-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
|
||||
---
|
||||
|
||||
# 209. A login on a node moves that node to the account it logged in to; an API key is added from any node, sealed
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)
|
||||
made a licence an account the manager learns from what the nodes report, adopted by refreshing it, and
|
||||
bound a node to a licence automatically only when it was bound to nothing (§7); every later change was a
|
||||
person's act through `bind` and `switch`. It went live on 2026-10-04 with one account, bound to all four
|
||||
nodes.
|
||||
|
||||
**The operator then asked what happens on a login to a second account on one node, and traced, the
|
||||
answer was wrong.** The manager adopts the second account as a new licence — and leaves the node bound to
|
||||
the first. The node is left holding the second account's access token, a refresh token the adoption just
|
||||
spent, and a binding to the first; at the first account's next rotation it is handed a token its own state
|
||||
file does not name. A login is the most direct thing a person does on a machine about which account it
|
||||
uses, and the mesh read it as a contribution of a grant only.
|
||||
|
||||
**An API key could enter only from a file on the manager's node** (ADR 0206, design 39 §6), so adding one
|
||||
meant reaching that machine. The operator asked for a streamlined process for both.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **A login on a node switches that node to the account logged in to.** Chosen.
|
||||
2. **Keep ADR 0206 §7, and have the person `switch` after logging in.** Rejected: the step is easy to
|
||||
forget and the state between the login and the switch is the broken one described above.
|
||||
3. **Refuse to adopt a login for an account other than the node's binding.** Rejected: it discards what
|
||||
the person plainly meant, and a second account could then enter only by a separate act.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A login on a node is that node's choice of account.** When the manager adopts a node's login (ADR
|
||||
0206 §4) — a new account, or a newer login of one it holds — it binds that node to the licence the login
|
||||
belongs to. If the node was bound to another licence, this is a switch: the node is handed the new
|
||||
licence's access token and its agent's account is pointed at it, as `switch` does. Every other node stays
|
||||
where it is. `bind`, `switch` and `release` remain for moving a node without a login.
|
||||
|
||||
**2. A login that does not refresh moves nothing.** The candidate is recorded dead (ADR 0206 §4) and the
|
||||
node keeps its binding; the person logs in again.
|
||||
|
||||
**3. An API key is added from any node, sealed, never as an argument.** The agent module serves a tool
|
||||
that reads a key from a file on its own node, seals it to the manager's public key — which the seat now
|
||||
serves as a verb — and hands it to the seat's `adopt` on request/reply; the file is removed once the
|
||||
manager has taken it. Optionally the same call binds that node to the new licence. The seat's `adopt`
|
||||
still also takes a file on the manager's node. An API key is a licence of its own, never an account's:
|
||||
nothing is learned about it from a report, and it moves a node only when a person says so.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Logging in on a node is the whole of moving that node to an account, new or known. The mesh's state
|
||||
stays consistent: the binding, the token on the node and the account its agent names agree.
|
||||
- A second account enters the mesh by one login, and only the node it was logged in on uses it.
|
||||
- **What got harder:** a person who logs in on a node to try an account moves that node; moving it back is
|
||||
`switch`. Said in the seat's own description of `switch`, and in the agent module's instruction file.
|
||||
- The key file on a node exists only until the manager has taken it; the key then lives encrypted in the
|
||||
manager's store alone (ADR 0183).
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A login for another account moves its node and no other | the manager's test: two accounts, the login on one node adopted, that node switched, the others unchanged |
|
||||
| A newer login of a known account on a node bound elsewhere moves that node | the manager's test |
|
||||
| A login that does not refresh moves nothing | the manager's test: the binding unchanged, the candidate dead |
|
||||
| An API key never crosses the bus in the clear and its file is gone afterwards | the agent module's test: the request carries a sealed box only; the file is removed after the seat answered |
|
||||
| Live | a login to a second account on one workstation: a second licence appears, that workstation is bound to it and its agent names it, the other nodes keep the first |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md) — the flow this extends; §7 is changed by decision 1
|
||||
- [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the manager, its seat and its channel
|
||||
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md), [to-be 39](../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md)
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md
|
||||
---
|
||||
|
||||
# 210. A tool's configuration is its seat holder's, and every other module extends it through the seat
|
||||
|
||||
## Context
|
||||
|
||||
One fault kept coming back while the machines' modules were rolled out
|
||||
([to-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)): two modules want the same
|
||||
thing on a node.
|
||||
|
||||
- The bar module and the package manager's module both declared the package that brings the
|
||||
package manager's helper scripts. The node stopped resolving
|
||||
([issue 235](../04-ISSUES/235-an-assignment-that-cannot-be-composed-is-recorded-anyway/00-report.md)).
|
||||
- The launcher, the clipboard manager, the wallpaper and the bar each wrote a file of their own into
|
||||
the window manager's include directory, as [ADR 0208](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md)
|
||||
§4 allowed. Nothing says those modules need the window manager. Assigned without it, they write
|
||||
configuration nothing reads. Assigned with a different session holder, they write into a directory
|
||||
that holder does not own.
|
||||
- [ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md) §5
|
||||
lets a module contribute to the environment on a node that has no holder: the contribution is
|
||||
written nowhere, and nothing says so.
|
||||
|
||||
The mesh already has the piece that answers this.
|
||||
[ADR 0207](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md) made a module depend on
|
||||
the seat that applies its resources, derived from what it declares. A contribution is the same kind
|
||||
of need. It is configuration that only the seat's holder can apply.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Let the first module to declare a file or package own it,** and refuse the second. Rejected:
|
||||
ownership then depends on the order modules were written, and the second module has no lawful way
|
||||
to say what it needs.
|
||||
2. **Allow shared declarations** of one package or file by several modules, merged by the host.
|
||||
Rejected: a shared file has no owner to answer for it, and removing one module cannot tell what it
|
||||
alone put there.
|
||||
3. **Each tool's configuration belongs to the module holding the tool's seat. Every other module
|
||||
extends it with a contribution to that seat, and a contribution is a dependency on the seat.**
|
||||
Chosen. It is the operator's statement of the rule.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. One owner.** A tool's configuration files, and the tool's package, belong to the module that
|
||||
holds the tool's seat on the node. Only that module writes them.
|
||||
- The package manager's configuration and helper packages are the package manager's module's.
|
||||
- The account's environment is node-environment's holder's.
|
||||
- The window manager's configuration is node-display-session's holder's.
|
||||
|
||||
**2. Other modules extend, never write.** A module that needs something in another tool's
|
||||
configuration declares a **contribution to that tool's seat**:
|
||||
- its content, in the grain the seat defines (variables and paths, shell code for a named shell and
|
||||
slot, a window-manager configuration fragment, a notifier rule);
|
||||
- never a path inside the holder's files or directories.
|
||||
|
||||
The holder places what it receives:
|
||||
- the controller renders the contributions into the holder's files through the holder's placeholders,
|
||||
as [ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md) and
|
||||
[ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md) already do;
|
||||
- or the holder writes each contribution to its tool's own drop-in directory. That directory is then
|
||||
the holder's resource, not the contributor's.
|
||||
|
||||
**3. A contribution is a dependency on the seat that receives it.** The controller derives it from the
|
||||
contribution, as ADR 0207 derives one from a resource. It is met, checked and refused exactly as ADR
|
||||
0207 §3 and §4 say:
|
||||
- refused at `assign` when no module on the node holds the seat and the catalogue has a holder;
|
||||
- refused at composition after the switch.
|
||||
|
||||
**4. Needing what another module's package delivers is the same.** A module that needs a program
|
||||
another seat's holder installs does not declare that package. It depends on the seat, and through
|
||||
the seat's verbs where they exist. One package is declared by one module on a node.
|
||||
|
||||
**5. A seat says what it receives.** A seat lists the contribution kinds its holder accepts. A
|
||||
contribution of a kind the seat does not list is refused at registration.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **ADR 0203 §5** no longer holds: a module contributing to the environment depends on
|
||||
node-environment, and a node without the holder refuses it. The rest of that record stands.
|
||||
- **ADR 0208 §4**: its first list, contributors placing their own files in another tool's directory,
|
||||
is replaced by §2 above. The tool's grain stays the guide for what a contribution contains. The
|
||||
`xinitrc` and `xresources` slots already work this way, and now carry a dependency on
|
||||
node-display-server.
|
||||
- **The desktop modules change:** the launcher, the clipboard manager, the wallpaper and the bar
|
||||
contribute their window-manager lines to node-display-session instead of writing into the include
|
||||
directory. The bar keeps relying on the package manager's helper scripts through node-package-manager.
|
||||
- **The two kinds of collision cannot recur:**
|
||||
- two modules declaring one package or one file;
|
||||
- a contribution to a seat nobody on the node holds.
|
||||
A composition that finds either is a fault in a module, not a state a node can be left in.
|
||||
- **What got harder:** a seat that receives contributions must define their grain, and its holder must
|
||||
place them. Each new kind is a small change to the controller's renderer or to the holder.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A contribution derives a dependency on the seat that receives it | the controller's resolve tests |
|
||||
| A contribution to a seat with no holder on the node is refused at `assign` when the catalogue has a holder | the same tests, and `assign` live |
|
||||
| One package or file is declared by one module on a node | the controller's composition test, and `module check` across the catalogue |
|
||||
| A contribution of a kind its seat does not list is refused | the catalogue check, which registration runs |
|
||||
| No module declares a path inside another module's files or directories | the catalogue check |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
|
||||
[ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
|
||||
[ADR 0207](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md),
|
||||
[ADR 0208](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md)
|
||||
- [Issue 235](../04-ISSUES/235-an-assignment-that-cannot-be-composed-is-recorded-anyway/00-report.md)
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
|
||||
---
|
||||
|
||||
# 211. A machine's power is a node seat, its moments take contributions, and its states are events
|
||||
|
||||
## Context
|
||||
|
||||
The laptop's module needs code to run around sleep:
|
||||
- the GPU driver's own suspend and resume actions;
|
||||
- a touchpad reset after waking.
|
||||
|
||||
It wrote drop-ins of its own into the service manager's sleep services, so it wrote into files that
|
||||
belong to another tool's holder. [ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)
|
||||
forbids exactly that. A second module wanting code after waking would do the same, and nothing would
|
||||
order the two or say that either needs sleep to be handled at all.
|
||||
|
||||
The mesh also cannot tell a sleeping machine from a lost one. A laptop with its lid closed stops its
|
||||
heartbeat exactly as a crashed machine does, and is reported "out of touch" either way.
|
||||
[Research 028](../01-RESEARCH/028-the-meshs-output-channel/00-overview.md) would turn every closed lid
|
||||
into an alert.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Each module writes its own sleep drop-ins,** as the laptop's did. Rejected by ADR 0210: no
|
||||
owner, no order, no dependency.
|
||||
2. **The service manager's holder takes power hooks.** Rejected: sleep and power are logind's and the
|
||||
firmware's concern, not service management's. On a laptop they also include lid, power source and
|
||||
battery, which the service manager knows nothing about.
|
||||
3. **A power seat on every machine.** Its holder:
|
||||
- owns the machine's power handling;
|
||||
- places code that modules contribute for named moments;
|
||||
- publishes the machine's power states as events.
|
||||
|
||||
Chosen. It was the operator's proposal.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. `node-power` is a node seat in the mesh's own set.** One module per node holds it. Every
|
||||
machine has one, servers included: every machine boots and shuts down. The first holder is a module
|
||||
named `power`.
|
||||
|
||||
**2. Its holder owns the machine's power handling:**
|
||||
- logind's power-key and lid settings;
|
||||
- the hooks around sleep, boot and shutdown;
|
||||
- the reading of power source and battery where the machine has them.
|
||||
|
||||
A model's specific values, such as what the lid does on that laptop, are the model's module's
|
||||
contribution or a setting of `power` per node (ADR 0174), never a second writer of logind's
|
||||
configuration.
|
||||
|
||||
**3. Modules contribute code for named moments.** The moments:
|
||||
- after boot;
|
||||
- before sleep;
|
||||
- after waking;
|
||||
- before shutdown;
|
||||
- on mains power;
|
||||
- on battery.
|
||||
|
||||
A contribution is POSIX shell code, written with ADR 0204's mechanism as ADR 0208 §4 did for the
|
||||
session's start:
|
||||
- a `shell` contribution whose `for` names the moment, in the `first`, `normal` or `last` slot;
|
||||
- placed by the holder with `${shell:<moment>:<slot>}` in the scripts its own units run;
|
||||
- run as root, in module order, each piece bounded in time, so that one module's hang cannot hold a
|
||||
machine awake.
|
||||
|
||||
Per ADR 0210, a contribution for a moment depends on `node-power`.
|
||||
|
||||
**4. Its states are the holder's events, on the bus:**
|
||||
- `booted`, `sleeping`, `woke`, `shutting-down`;
|
||||
- `on-mains`, `on-battery`, `battery-low`, where the machine has a battery.
|
||||
|
||||
They carry the machine's role and a time, and nothing secret, so any node and the controller may
|
||||
consume them.
|
||||
- **`sleeping` is published before the machine sleeps.** The holder takes logind's delay lock,
|
||||
publishes, and releases the lock once the bus has acknowledged, within logind's delay bound.
|
||||
- **On waking,** the holder queues events until the bus is reachable, then publishes them in order.
|
||||
|
||||
**5. A machine that said `sleeping` is asleep, not out of touch,** until it says `woke` or misses its
|
||||
expected return. The controller shows the state, and the output channel (research 028) does not
|
||||
treat a sleeping machine as a fault.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The laptop's module moves its sleep drop-ins into contributions:
|
||||
- the GPU driver's suspend and resume actions before sleep and after waking;
|
||||
- its touchpad reset after waking.
|
||||
|
||||
Its own files in the service manager's directories go.
|
||||
- Assigning `power` to every machine is phase 1 of [to-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md).
|
||||
- The controller gains the moments as contribution targets placed by `node-power`, and a node's
|
||||
power state in what it shows about the node.
|
||||
- **What got harder:**
|
||||
- code that must run at a precise point inside the sleep transaction cannot be a contribution; the
|
||||
GPU driver's own units are an example. Such code still declares its own units, and only the
|
||||
request to run them is contributed;
|
||||
- an event published around sleep depends on the network still being up. The delay lock buys the
|
||||
time, and if the bus does not answer within it, the machine sleeps anyway and says so on waking.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A moment's contribution derives a dependency on `node-power`, and lands in that moment's placeholder in module order | the controller's contribution tests |
|
||||
| A contribution naming an unknown moment is refused | the catalogue check |
|
||||
| One piece of hook code that hangs is ended after its bound, and the next still runs | the power module's tests over real child processes |
|
||||
| `sleeping` is published before sleep and `woke` after, and a missed acknowledgement does not hold the machine awake | the power module's tests with a fake logind and bus, and a live suspend of the laptop |
|
||||
| A machine that said `sleeping` is not reported out of touch | the controller's status test |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md),
|
||||
[ADR 0198](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md),
|
||||
[ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
|
||||
[ADR 0208](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md),
|
||||
[ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)
|
||||
- [Research 028](../01-RESEARCH/028-the-meshs-output-channel/00-overview.md)
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
|
||||
---
|
||||
|
||||
# 212. A seat says what it receives, and the machine's hotkeys are a seat
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)
|
||||
decided that a tool's configuration belongs to its seat's holder, and that every other module extends
|
||||
it with a contribution to the seat (§2), in a grain the seat defines (§5). The controller knows only
|
||||
three such grains:
|
||||
- the environment ([ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md));
|
||||
- shell code in named slots ([ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md));
|
||||
- the power moments ([ADR 0211](0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md)).
|
||||
|
||||
Each was a field of its own, with a renderer of its own. Two more appeared on the first workstation:
|
||||
|
||||
- **The window manager.** Four modules, the launcher, the clipboard, the wallpaper and the bar, wrote
|
||||
files of their own into its include directory, and so did the laptop's model module. That is what
|
||||
ADR 0210 forbids.
|
||||
- **The keys the window manager never sees.** A laptop's vendor keys reach only a hotkey daemon, which
|
||||
reads trigger lines (a key, a state, a command). The daemon's configuration was the laptop module's,
|
||||
although the daemon is a general piece that more than one module has keys for.
|
||||
|
||||
A field and a renderer per grain would make every new seat a change to the controller's schema.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **A field per grain,** as before. Rejected: the manifest and the controller grow with every seat
|
||||
that takes contributions.
|
||||
2. **Contributions as files in the holder's drop-in directory,** each contributor writing its own.
|
||||
Rejected by ADR 0210: a path in another module's territory.
|
||||
3. **One general contribution: a seat, a kind the seat receives, and text in the tool's own grammar.**
|
||||
The seat lists the kinds it receives. The holder places each kind with one placeholder, and the
|
||||
controller concatenates the contributions in module order, each under a comment naming its module.
|
||||
Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A module contributes with `contributions`.** Each entry names:
|
||||
- a **seat**;
|
||||
- a **kind**, which that seat receives;
|
||||
- **content**, text in the tool's own grammar, which the controller does not read.
|
||||
|
||||
**2. A seat lists the kinds it receives,** with the comment prefix of its tool's grammar. A
|
||||
contribution of a kind its seat does not list is refused at registration.
|
||||
|
||||
**3. The holder places a kind with `${contribution:<seat>:<kind>}`** in its own files. The placeholder
|
||||
is filled with every module's contribution of that kind on the node:
|
||||
- in module order;
|
||||
- each preceded by a comment line naming the module;
|
||||
- empty when there is none.
|
||||
|
||||
A placeholder in a module that does not claim the seat is refused, as ADR 0204 refuses shell slots.
|
||||
|
||||
**4. A contribution depends on its seat** (ADR 0210 §3), derived and refused as
|
||||
[ADR 0207](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md) says.
|
||||
|
||||
**5. Two seats receive first:**
|
||||
|
||||
| seat | kind | what it is |
|
||||
|---|---|---|
|
||||
| `node-display-session` | `config` | window-manager configuration lines: bindings, start-up commands, rules |
|
||||
| `node-hotkeys` (new, node scope) | `trigger` | hotkey-daemon trigger lines: a key, a state, a command |
|
||||
|
||||
`node-hotkeys` is in the mesh's own set. Its holder runs the daemon that sees the keys the window
|
||||
manager does not, and owns that daemon's configuration and service.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The window-manager fragments become `config` contributions of their modules: the launcher, the
|
||||
clipboard, the wallpaper, the bar and the laptop model. The window manager's module places them
|
||||
instead of including other modules' files.
|
||||
- A hotkey module holds `node-hotkeys`. The laptop's model module contributes its vendor keys
|
||||
instead of writing the daemon's trigger file, and keeps only what is its own: the scripts the keys
|
||||
run.
|
||||
- The three earlier grains stay as they are. Folding them into this form is a later change, not
|
||||
required by this record.
|
||||
- **What got harder:** a contribution is text the controller does not read, so a malformed line
|
||||
reaches the tool. The holder checks the composed file with the tool's own check where the tool has
|
||||
one (the window manager's), before it reloads.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A contribution names a seat and a kind that seat receives | the catalogue check, which registration runs |
|
||||
| The placeholder fills with every module's contribution, in module order, each named | the controller's contribution tests |
|
||||
| A placeholder outside the seat's holder is refused | the catalogue check |
|
||||
| A contribution derives a dependency on its seat | the controller's resolve tests |
|
||||
| `node-hotkeys` is a node seat of the mesh's own set | the seat table's tests |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0203](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
|
||||
[ADR 0204](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
|
||||
[ADR 0208](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md),
|
||||
[ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md),
|
||||
[ADR 0211](0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md)
|
||||
- [To-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||
---
|
||||
|
||||
# 213. The operator sets the agent's managed settings through the agent module, under the mesh's own keys
|
||||
|
||||
## Context
|
||||
|
||||
The agent module writes the agent's machine-wide managed settings file
|
||||
([to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §2). It carries the mesh's
|
||||
own keys only: the attribution convention of its repositories, the connectors kept beside the managed
|
||||
tool servers, and the key-helper for an API-key licence. Every other key was left to the person's own
|
||||
settings, so that the mesh never reverts a person's choice on a push.
|
||||
|
||||
That left no place for a rule the **operator** wants to hold in every session on a machine: what the
|
||||
agent may do without asking, what it must never do, and what its unattended mode allows. These keys
|
||||
are not preferences. They are policy about what an agent may do on the mesh. Set by hand in one
|
||||
person's settings on each machine, they are unmanaged state the mesh cannot see, and the agent refuses
|
||||
to change them itself, as it should.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Leave them to each person's settings.** Rejected: policy by hand on each machine, invisible to
|
||||
the mesh, and a session cannot be asked to loosen its own permissions.
|
||||
2. **A field per vendor key** (permissions, auto mode, environment, hooks) in the module's settings.
|
||||
Rejected: the vendor adds keys, and every one would be a change to the module.
|
||||
3. **One setting holding managed-settings keys, laid under the mesh's own.** The operator sets it
|
||||
for the mesh or for one node through the controller's settings verb. The module copies its keys into
|
||||
the managed settings file, then lays the mesh's keys over them.
|
||||
|
||||
## Decision
|
||||
|
||||
Option 3.
|
||||
|
||||
1. The agent module takes a setting, `managed_settings`: an object in the vendor's settings shape. It
|
||||
is set for the whole mesh or for one node, like the module's other settings, through the
|
||||
controller's settings verb.
|
||||
2. The managed settings file is that object with **the mesh's keys laid last**: the attribution
|
||||
convention, the connectors kept beside the managed servers, and the key-helper. A setting can
|
||||
neither replace one of these nor add a key-helper that the binding did not ask for.
|
||||
3. Only the operator sets it, and it is declared state like the role and the extra tool servers. A
|
||||
person's preferences stay in their own settings; the mesh still sets none of them by itself.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The operator's rules for the agent are declared once, for the mesh or per node, and reach every
|
||||
node at the next push. A rule set in the managed settings outranks every other scope, so it holds
|
||||
in every session on the node.
|
||||
- A setting layer is replaced whole by the controller's verb. Setting this key without the role or
|
||||
the extra tool servers clears those in that layer; the module's documentation says so.
|
||||
- **What got harder:** a person cannot override a rule set here, which is the point. A rule that is
|
||||
wrong is wrong on every session of the node until the operator changes the setting.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The operator's keys reach the managed settings file | the agent module's test: an auto-mode allow list and a permissions list set in the setting appear in the rendered file |
|
||||
| The mesh's keys always win | the same test: a setting naming the attribution, the connectors key or a key-helper is overridden, and a key-helper appears only for an API-key binding |
|
||||
| Live | the setting given for the mesh; the managed settings file on each node carries the key after the next push |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the agent module and the files it writes
|
||||
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) — the design this amends (§2, §6)
|
||||
- the vendor's documentation on managed settings and their precedence
|
||||
@@ -188,7 +188,12 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **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)
|
||||
- **0187** — [A dead tracker is not the machine's failure](0187-a-dead-tracker-is-not-the-machines-failure.md)
|
||||
- **0189** — [The store keeps what the records name, and a maintenance step holds its writers still](0189-the-store-keeps-what-the-records-name.md)
|
||||
- **0190** — [A seat's work is shared by its holders, and building is the first such role](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md)
|
||||
- **0202** — [A provider declares what it derives for each consumer, and the mesh tells both ends](0202-a-provider-declares-what-it-derives-for-each-consumer.md)
|
||||
- **0207** — [A module depends on the node seats that apply its resources](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)
|
||||
- **0210** — [A tool's configuration is its seat holder's, and every other module extends it through the seat](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)
|
||||
- **0212** — [A seat says what it receives, and the machine's hotkeys are a seat](0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md)
|
||||
|
||||
### Its tiers, from the bottom up
|
||||
|
||||
@@ -225,6 +230,7 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0191** — [The mesh's resolver holds only the mesh's own names; a public name resolves publicly](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)
|
||||
- **0194** — [The mesh has one resolver, and every node asks it for the mesh's names](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)
|
||||
- **0196** — [A node asks the mesh's resolver first, and a public one only when it is silent](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md)
|
||||
- **0199** — [A module that answers names declares its zone, and a node's hosts file is one module's](0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)
|
||||
|
||||
### What runs on them, and how it gets there
|
||||
|
||||
@@ -298,6 +304,15 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0195** — [The mesh's tools are found by address, not announced whole](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)
|
||||
- **0197** — [Every tool announces itself on the bus, in the NATS services protocol](0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)
|
||||
- **0198** — [A module's long-running code is launched by the node's runtime, and reaches the bus through it](0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)
|
||||
- **0201** — [A module keeps its current state in key-value buckets it declares, and reaches them through the runtime](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)
|
||||
- **0203** — [The account's environment is one module's, and every module contributes to it](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)
|
||||
- **0204** — [A module contributes shell code to the login shell in named slots, and the login shell is the mesh's seat](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
|
||||
- **0205** — [Software the distribution does not package ships as a pinned archive of the module's own](0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md)
|
||||
- **0206** — [A node reports the Anthropic grant it holds; the licence manager adopts a licence by refreshing it, and what each node should hold is the manager's state](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)
|
||||
- **0208** — [The graphical session is one module per piece, on the mesh's seats](0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md)
|
||||
- **0209** — [A login on a node moves that node to the account it logged in to; an API key is added from any node, sealed](0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md)
|
||||
- **0211** — [A machine's power is a node seat, its moments take contributions, and its states are events](0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md)
|
||||
- **0213** — [The operator sets the agent's managed settings through the agent module, under the mesh's own keys](0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)
|
||||
|
||||
### How it is built
|
||||
|
||||
@@ -320,6 +335,7 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0111** — [A build source is on the mesh's git seat, or it is an external repository](0111-a-build-source-is-on-the-git-seat-or-external.md)
|
||||
- **0149** — [The live mesh is the test bed](0149-the-live-mesh-is-the-test-bed.md)
|
||||
- **0174** — [A node varies a module through settings and kept regions, never through an edit](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
|
||||
- **0200** — [Genesis pivots to the controller as a container, and the first push hands it to a process](0200-genesis-pivots-to-the-controller-as-a-container-and-the-first-push-hands-it-to-a-process.md)
|
||||
|
||||
### How it is checked
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
layer: as-is
|
||||
status: implemented
|
||||
code: [mesh-controller internal/catalogue/state.go, mesh-controller internal/broker/state.go, mesh-controller cmd/mesh-controller/push.go, mesh-tools node-tools/internal/bus/state.go, mesh-tools node-tools/internal/launch/launch.go, mesh-sdk src/state, mesh-sdk go/state.go]
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
|
||||
---
|
||||
|
||||
# A module's state, as it runs
|
||||
|
||||
**A module keeps the current value of something on the bus, and every machine sees it — including one
|
||||
that joins later.** Since 2026-10-04 a manifest may say `state` (buckets the module owns) and `reads`
|
||||
(another module's, as `<module>.<name>`). The first two modules to use it are the operator's agent on a
|
||||
machine and its licence manager; on the day this was written, three buckets existed on the bus.
|
||||
|
||||
## What runs
|
||||
|
||||
- **The controller** creates a key-value bucket `<module>_<name>` for every declared state, from the
|
||||
catalogue — on every start and, since the first module that declared state found it missing, on every
|
||||
push before the memberships that name it. A bucket nothing declares any more is reported and kept.
|
||||
Every bucket carries the mesh's caps: 256 KiB a value, 64 MiB a bucket.
|
||||
- **The grants**: the machine's runtime is granted, for each bucket a module it carries owns, writing
|
||||
under the bucket's own subjects and reading; for a bucket it only reads, reading. Measured once built,
|
||||
with the composed grants loaded into a server: a reader's write is refused by the server.
|
||||
- **The membership** issued to each assignment lists its buckets by the names the module uses, and
|
||||
whether it may write.
|
||||
- **The runtime** answers `mesh/state.get`, `put`, `delete`, `keys` and `watch` on the bundle's channel.
|
||||
A watch hands the current values — none that is deleted — then every change, each naming the watch it
|
||||
belongs to; it is answered once the current values are delivered. The runtime refuses, with the
|
||||
reason, a state the module was not issued, a reader's write, a key the bus cannot hold, and a value
|
||||
carrying a field named like a credential.
|
||||
- **The SDKs**: `state(name)` in TypeScript, `stdio.State(name)` in Go (tag `go/v0.1.7` and later).
|
||||
|
||||
## What the first live use showed
|
||||
|
||||
- **A refused request is a timeout, not a refusal.** The bus reloads a machine's grants a moment after
|
||||
the push that changed them; a bundle that asks in between waits out its deadline. A module that
|
||||
watches at start therefore watches beside its handshake and asks again until the state answers — the
|
||||
agent module needed two to seven attempts on its first start on each machine.
|
||||
- **A late machine reads the whole set.** A server registered for every machine before one machine was
|
||||
assigned the module reached that machine from the current values at its start.
|
||||
- **The secrets guard is partial and works for what it covers**: an entry carrying an `Authorization`
|
||||
header was refused on the live bus. A sealed value is plain text to an inspector, and is not caught.
|
||||
|
||||
## How it is checked
|
||||
|
||||
The controller's catalogue and broker tests (names, grants, memberships, a bucket asserted in place
|
||||
against a real server); the runtime's tests over a real bus (current values without deletions, refusals,
|
||||
the TypeScript SDK through the runtime); `module check` names a read whose owner on the shelf keeps no
|
||||
such state.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
layer: as-is
|
||||
status: implemented
|
||||
code: [mesh-catalog modules/claude-code, mesh-catalog modules/claude-licence-manager]
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
|
||||
- 02-DECISIONS/0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md
|
||||
---
|
||||
|
||||
# The operator's agent and its licences, as they run
|
||||
|
||||
**Every machine with an operator account runs the agent module, and one licence manager on the control
|
||||
node keeps the licences.** Both are Go binaries the machine's runtime launches; neither has a container,
|
||||
a port or a bus credential of its own. Live since 2026-10-04, on all four machines.
|
||||
|
||||
## The agent module on each machine
|
||||
|
||||
- **Writes the agent's managed directory**: the tool servers — the console as `mesh`, plus servers
|
||||
registered through the module — the mesh's settings, and the instruction file. The tool-server list
|
||||
is exclusive by the vendor's rule: a server not in it does not load on that machine.
|
||||
- **Keeps registered tool servers in its state**, one key per registration for every machine or for one;
|
||||
each machine renders what applies to it.
|
||||
- **Reports what its machine holds** — the account its agent names, the kind, fingerprints and expiries,
|
||||
never a token — at start and whenever the credentials file changes.
|
||||
- **Writes what the machine should hold**: on a newer generation of its binding it asks the seat's
|
||||
`current`, sealed to its own key, and writes the access token only. No machine holds a refresh token.
|
||||
- **Hands over a login when asked**, sealed to the manager's key, and **adds an API key** from a file on
|
||||
its machine the same way, removing the file once the manager has it.
|
||||
|
||||
## The licence manager on the control node
|
||||
|
||||
- **Holds the `anthropic-licence-manager` seat**: `licences`, `bindings`, `bind`, `switch`, `release`,
|
||||
`refresh`, `usage`, `adopt`, `public-key`, `current`.
|
||||
- **Learns licences from the reports**: a refresh token it does not hold is adopted by refreshing it,
|
||||
newest login first, once per account. The machine a login was made on is moved to that login's
|
||||
account; a machine bound to nothing is bound to the account it reports.
|
||||
- **Is the only refresher**: every exchange under a lease per licence in its own database, every four
|
||||
hours and in any case within an hour of expiry; grants are encrypted at rest with a key the vault made.
|
||||
- **Publishes what each machine should hold** as its `bindings` state, with a generation that grows with
|
||||
every rotation and switch.
|
||||
|
||||
## On the day it went live
|
||||
|
||||
One subscription account was adopted from the control node's own login on its first start; the other
|
||||
three machines, logged in to the same account with older logins, were bound to it without their logins
|
||||
being exchanged. A forced rotation reached all four machines within seconds. Two faults were found and
|
||||
fixed during the rollout: a machine reporting an already-adopted account later was never bound, and a
|
||||
seat verb named with an underscore was refused by the builder.
|
||||
|
||||
## How it is checked
|
||||
|
||||
Each module's own tests (the agent's instruction file held byte for byte to the renderer it replaced; the
|
||||
manager's rules on a store in memory and a stub vendor; its store against a real database); one run of
|
||||
both binaries under the real runtime with a stub vendor before going live; and live: `licences` lists the
|
||||
licence with every machine bound, and each machine's `claude_code_status` names it with no login waiting.
|
||||
@@ -22,6 +22,8 @@ Where the two disagree, the implementation wins and the disagreement is stated.
|
||||
| [`11-the-lab.md`](11-the-lab.md) | The lab — the first piece of the new shape that exists, and what it does not yet do |
|
||||
| [`12-the-seats.md`](12-the-seats.md) | The seats the mesh defines, who holds one, and where a seat changes resolution |
|
||||
| [`13-the-console.md`](13-the-console.md) | The mesh's tools on the machine a person sits at, served by a module the mesh assigned there |
|
||||
| [`14-a-modules-state.md`](14-a-modules-state.md) | A module's current state on the bus: what the controller creates, the runtime serves, and the first live use showed |
|
||||
| [`15-the-agent-and-its-licences.md`](15-the-agent-and-its-licences.md) | The operator's agent on every machine and the licence manager that keeps its licences |
|
||||
|
||||
## What these documents are not
|
||||
|
||||
|
||||
@@ -6,10 +6,16 @@ code:
|
||||
- mesh-controller examples/route-proxy
|
||||
- mesh-controller internal/identity/authority.go
|
||||
- mesh-controller cmd/mesh-controller/plan.go (the names the roster publishes)
|
||||
- mesh-controller internal/catalogue/zones.go (the zones a module answers, ADR 0199)
|
||||
- mesh-controller internal/catalogue/seats.go (mesh-dns-resolver, node-hosts-file)
|
||||
- mesh-catalog modules/dnsmasq (the mesh's one resolver)
|
||||
- mesh-catalog modules/resolv-conf (what a node asks)
|
||||
- mesh-catalog modules/hosts (a node's /etc/hosts)
|
||||
- mesh-host internal/identity/serving.go
|
||||
- mesh-host internal/apply (the service that reflects a rule set)
|
||||
updated: 2026-10-03
|
||||
decisions:
|
||||
- 02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md
|
||||
- 02-DECISIONS/0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md
|
||||
- 02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
|
||||
- 02-DECISIONS/0191-the-meshs-resolver-holds-only-the-meshs-own-names.md
|
||||
@@ -377,6 +383,16 @@ member's resolver answers a LAN; a router pointing at one is moved first. *Check
|
||||
`/etc/resolv.conf` naming `mesh-resolver` then a public resolver, by no node but the holder answering
|
||||
DNS on any address, and by the router's DHCP DNS option naming the router.*
|
||||
|
||||
**Names that are neither a node nor a route.** A module that answers names declares a zone (a
|
||||
setting) and the listen that answers it; the controller hands the `mesh-dns-resolver` holder every
|
||||
zone with its module's node address and published port, and the holder forwards that zone there and
|
||||
answers nothing in it itself — the lab answers `<machine>.incus` for its running scenarios this way.
|
||||
An operator's own names, unrelated to the mesh, live in `/etc/hosts`'s kept region, held per node by
|
||||
the `node-hosts-file` seat's holder and changed through its tools; the controller holds none of them
|
||||
([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)).
|
||||
*Checked by the holder's configuration carrying one forwarding rule per declared zone, and by a push
|
||||
leaving the hosts file's operator region byte for byte.*
|
||||
|
||||
*What follows describes the per-node resolver this replaces — how it was built and why the roles were
|
||||
split. The split stands; the serving role's scope is what moved.*
|
||||
|
||||
@@ -1021,6 +1037,8 @@ The list is worth having in one place, because it is most of the argument:
|
||||
|
||||
- **One resolver ([ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)).** Not built: every node still runs
|
||||
`node-dns-resolver`. The migration's four steps are in the record, in order.
|
||||
Nor are zones or the hosts file's holder ([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)): the
|
||||
workstation moves to the one resolver only once both exist, its lab and operator names depending on them.
|
||||
|
||||
- ~~**What happens when the hub is down.**~~ **Resolved** by
|
||||
[ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md), together with `06`'s
|
||||
|
||||
@@ -5,10 +5,11 @@ code:
|
||||
- mesh-controller cmd/mesh-builder
|
||||
- mesh-controller internal/builder
|
||||
- mesh-catalog modules/build-agent
|
||||
updated: 2026-10-03
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md
|
||||
- 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
|
||||
- 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
|
||||
- 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
|
||||
- 02-DECISIONS/0142-the-mesh-delivers-its-own-components-as-binaries.md
|
||||
- 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md
|
||||
@@ -317,6 +318,44 @@ build's lines reach a reader of its subject in order and the stream holds them a
|
||||
against a real server); the seat verb with an id reads the log (controller test); and, live, a build
|
||||
after the roll-out read line by line through the console.
|
||||
|
||||
## The store keeps what the records name
|
||||
|
||||
*2026-10-02 — [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md),
|
||||
[issue 108](../../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md).*
|
||||
|
||||
Every build pushes another layer set and, until this, nothing ever removed one. The registry's own
|
||||
answer — collect what no tag names — is wrong for this mesh: each artifact is pushed under one
|
||||
moving tag and machines are pinned by digest, so every build but the newest is untagged and some
|
||||
machine may still be running it.
|
||||
|
||||
**The mesh decides and the store reclaims.** Deletion is enabled on the store's one door — that
|
||||
door already accepts a push, and a writer who can push can replace any tag, so delete takes
|
||||
nothing a push did not already have, and ADR 0082's bargain (a store every machine reaches with no
|
||||
credential to distribute first) is kept. The mesh then removes what it put there and no longer
|
||||
keeps, **naming it from its own build records** rather than enumerating the store: it has never
|
||||
put anything there it did not record, so a digest it did not record making is never named, which
|
||||
is what keeps the sweep away from the images genesis pushed before any record existed.
|
||||
|
||||
An artifact stays for one of two reasons and otherwise goes: a definition the mesh holds names it
|
||||
(no age limit — this is the floor), or it belongs to one of the five most recent successful builds
|
||||
of its module (somewhere for a wrong release to return to). The sweep runs after a build the mesh
|
||||
recorded, which is the moment new bytes landed and the moment the keep set moved; it needs no
|
||||
timer. Deleting a manifest frees no bytes, so the store's own collector runs nightly as a
|
||||
scheduled step with the server held still — which is what `while-stopped` exists for
|
||||
([design 20](20-writing-a-module.md)). Plain collection, not `--delete-untagged`: what the mesh
|
||||
keeps is still a manifest and so still referenced, and the dangerous flag is not needed once the
|
||||
mesh is the one deciding.
|
||||
|
||||
A machine behind by more than five builds of a module, recreating a container, cannot pull what it
|
||||
was running. It is already a machine the mesh reports as behind, and the answer is the current
|
||||
declaration.
|
||||
|
||||
*How it is checked:* the keep set, against records, holds what a manifest names and the five most
|
||||
recent builds and nothing else; a reference the mesh never recorded is never in the delete set; an
|
||||
image and an archive are asked for at their own endpoints; a store with deletion off names the
|
||||
remedy rather than the status code; a store that does not have it is recorded collected rather
|
||||
than retried for ever. Live: the store's size before and after the first nightly collection.
|
||||
|
||||
## The builder compiles the languages the mesh is written in
|
||||
|
||||
*2026-09-29 —
|
||||
|
||||
@@ -5,11 +5,12 @@ code:
|
||||
- mesh-catalog modules/showcase
|
||||
- mesh-controller internal/builder
|
||||
- mesh-sdk src
|
||||
updated: 2026-09-30
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
|
||||
- 02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md
|
||||
- 02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md
|
||||
- 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
|
||||
- 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md
|
||||
- 02-DECISIONS/0040-what-a-module-is.md
|
||||
- 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md
|
||||
@@ -208,3 +209,27 @@ is recreated with the new fact
|
||||
([ADR 0099](../../02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md)). *How it is
|
||||
checked:* the host's unit tests run a step again when its named file changed and not otherwise,
|
||||
and recreate a container naming a step after the step ran.
|
||||
|
||||
## A recurring step may hold its own module's containers still
|
||||
|
||||
*Written 2026-10-02, from [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)
|
||||
and [issue 108](../../04-ISSUES/108-the-registry-has-no-garbage-collection-once-it-has-two-doors/00-report.md).*
|
||||
|
||||
Some work cannot be done underneath a running service: an artifact store's collector walks the
|
||||
storage and requires every writer stopped. A `run-once` step runs *beside* containers and a
|
||||
scheduled one is the same container again, so until this a module had no way to say it — and the
|
||||
mesh inherited a store that has never collected anything, because the predecessor said it with a
|
||||
shell script and a script beside a module is not a resource in it.
|
||||
|
||||
A scheduled step may name `while-stopped`: resource ids of **its own module's** containers, which
|
||||
the host stops before the run and starts again after it, in the reverse order, **whatever the step
|
||||
did**. Three boundaries, each refused where it can be seen earliest — its own module's containers
|
||||
only, because a module that could quiesce a neighbour could stop the mesh; scheduled steps only,
|
||||
because at apply the declaration is applied in order and a step already gates what follows, so a
|
||||
one-time offline job says *before* rather than *instead of*; and restoring that is not conditional
|
||||
on anything, because the only real risk of the field is a window that never closes.
|
||||
|
||||
*How it is checked:* the host's unit tests assert stop–run–start in that order, the restart after a
|
||||
step that **failed**, the reverse order for several containers, and a service left down said
|
||||
loudly. The controller refuses, from the definition alone, a window with no schedule, one on a
|
||||
run-once step, one naming a container the module does not declare, and one naming itself.
|
||||
|
||||
@@ -7,8 +7,10 @@ code:
|
||||
- mesh-tools src/broker-amqp.ts (to be replaced)
|
||||
- mesh-catalog modules/nats (to be written)
|
||||
- mesh-sdk src (the protocol's NATS binding, step 3)
|
||||
updated: 2026-10-02
|
||||
- mesh-tools node-tools/internal/bus (a module's state, ADR 0201)
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
|
||||
- 02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md
|
||||
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
|
||||
- 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
|
||||
@@ -48,6 +50,7 @@ mesh's own state lives, and where what a module may say is decided by what it de
|
||||
| **events** — a module says something happened | 1:many | delivered to every consumer that declared it; dead-lettered when it cannot be |
|
||||
| **tools** — a module or a person asks another's tool | request/reply | one answer, from one server, or a timeout |
|
||||
| **work to a role** — a module submits to a capability without knowing who provides it | job | exactly one holder does it; it queues while nobody does |
|
||||
| **state** — a module's current value of something, every machine reading it | key-value | the newest per key, kept until replaced or deleted; read whole by a machine that joins later |
|
||||
|
||||
The last two rows are the ones worth dwelling on, because they are not messaging in the sense of
|
||||
carrying bytes from A to B. **A role is addressable**, so a caller names the capability and never
|
||||
@@ -56,7 +59,8 @@ changing ([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)
|
||||
property of the mesh's architecture that happens to be expressed in subjects.
|
||||
|
||||
And more of the mesh lands here as it is built: conditions and observed state in key-value
|
||||
buckets that anything may watch, the server's own advisories becoming observations like any other
|
||||
buckets that anything may watch — the first of them a module's own declared state, *2026-10-04*
|
||||
([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)) — the server's own advisories becoming observations like any other
|
||||
([research 017](../../01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md)), and a person's
|
||||
client speaking the bus directly rather than through a surface built over it (§7). None of that
|
||||
is a message being moved; all of it is the bus being the mesh's centre.
|
||||
@@ -84,8 +88,16 @@ mesh.seat.<seat>.event.<verb> a role's own event (JetStream: EVENT
|
||||
mesh.seat.<seat>.tool.<verb> a role's tool (core request/reply)
|
||||
mesh.ask.<node>.<command> the controller's command api (core request/reply)
|
||||
mesh.assignment.<node>.<module> an assignment's membership (JetStream: ASSIGNMENTS, last-per-subject)
|
||||
$KV.<module>_<name>.<key> a module's state (JetStream: a key-value bucket per declared name)
|
||||
```
|
||||
|
||||
**Added 2026-10-04** ([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)):
|
||||
the last row is outside `mesh.` on purpose. A key-value bucket is NATS's own construct and lives
|
||||
under NATS's own prefix, which is what lets the server's key-value layer — direct reads, rollups,
|
||||
delete markers, watches — do the work instead of the mesh writing it again. The bucket is named for
|
||||
the module and the local name joined by an underscore, which neither may contain, so two modules can
|
||||
never derive one bucket.
|
||||
|
||||
**Revised 2026-10-01** ([ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)): the rows above
|
||||
for a module's and a seat's tools are the shapes the controller *issues*, not rules a runtime carries.
|
||||
Every assignment is published a membership — what it serves and where, in which queue, its seat verbs,
|
||||
@@ -155,6 +167,7 @@ Core NATS is at-most-once. Everything the mesh must not lose lives in a JetStrea
|
||||
| CONTROL | `mesh.control.>` except `alive` (a build's outcome moved to its seat, ADR 0121) | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped |
|
||||
| NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest |
|
||||
| EVENTS | `mesh.mod.*.event.>` and `mesh.seat.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream). *2026-10-01:* a build's whole log is here too, as the build-machine seat's `log.<build id>` events ([ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)) — one subject per build, a week of retention, read back by `builds --log <id>` with a consumer that is gone when the reading is done |
|
||||
| `KV_<module>_<name>` | `$KV.<module>_<name>.>` | the newest value per key — as many past values as the owner declared — no age unless the owner declared one; a value at most 256 KiB, a bucket at most 64 MiB | a module's state (ADR 0201): one per name in a manifest's `state`, created from the catalogue on every raise, so it exists before its owner runs anywhere; kept when the module is unassigned, because what it holds is data |
|
||||
|
||||
Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool
|
||||
call is a timeout the caller already handles.
|
||||
@@ -234,6 +247,14 @@ expresses this exactly, per subject, and better than a vhost could:
|
||||
permissions for each consumed event's subject, its tool subjects, and that same inbox prefix.
|
||||
Nothing else. A module that tries to publish outside its emits is refused by the server, not by
|
||||
convention.
|
||||
- **A module's state** (ADR 0201), for whichever principal carries the module — today the machine's
|
||||
runtime, whose grant is the union of its modules': binding to the bucket, reading a key directly,
|
||||
and an ordered consumer for listing and watching, created and deleted on the bucket's own stream
|
||||
and nothing else's; and, for the owner's instances only, publishing under the bucket's own
|
||||
`$KV.<bucket>.>`. *Measured 2026-10-04 against a running server:* without the consumer-delete
|
||||
grant a watch cannot be stopped cleanly, and a write the server refuses reaches the writer as a
|
||||
timeout rather than a refusal — so the runtime refuses first, from the membership, and the grant
|
||||
is the second line.
|
||||
- **The controller's user** owns `mesh.control.>`, `mesh.node.>` and the streams, and may submit work
|
||||
to the seats the mesh's own flows use — a build, for one (ADR 0121).
|
||||
**A host's user** may publish its own `mesh.control.<node>.>` and subscribe its own
|
||||
|
||||
@@ -12,6 +12,7 @@ code:
|
||||
- mesh-catalog modules/gitea/module.json
|
||||
updated: 2026-10-03
|
||||
decisions:
|
||||
- 02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md
|
||||
- 02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
|
||||
- 02-DECISIONS/0161-what-deserves-a-seat.md
|
||||
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
|
||||
@@ -129,6 +130,7 @@ convention, which later seats departed from.
|
||||
| `mesh-build-machine` | `the-build-machine` | node | — | a builder |
|
||||
| `mesh-resolver` | — | mesh | — | the mesh's one resolver, holding every node's internal domain ([ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)) |
|
||||
| ~~`mesh-dns-port`~~ | `the-dns-port` | node | — | retired by [ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md): the local resolver became the mesh's one |
|
||||
| `node-hosts-file` | — | node | — | owns `/etc/hosts`: the machine's own lines and the operator's kept region, changed through its verbs `entries`, `add`, `remove` ([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)) |
|
||||
| `mesh-intrusion-prevention` | `the-intrusion-prevention` | node | — | an intrusion-prevention service |
|
||||
| `mesh-packet-filter` | `the-packet-filter` | node | — | the packet filter |
|
||||
| `mesh-private-network` | `the-private-network` | node | — | the private network the mesh runs over |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [mesh-controller internal/catalogue]
|
||||
updated: 2026-09-30
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 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
|
||||
@@ -14,6 +14,7 @@ decisions:
|
||||
- 02-DECISIONS/0084-which-provider-serves-a-consumer.md
|
||||
- 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md
|
||||
- 02-DECISIONS/0038-the-mesh-assigns-the-port.md
|
||||
- 02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md
|
||||
---
|
||||
|
||||
# 27 — A module requires, the mesh resolves
|
||||
@@ -206,6 +207,22 @@ name when nothing sets it. That is the contract half of this design's operator p
|
||||
the placeholder allows: the definition says which values reach which requirement, and nothing else
|
||||
does. *How it is checked:* the unit tests named in issue 173, and the plan comparison that closed it.
|
||||
|
||||
*A provider says once what it derives for each consumer (2026-10-02,
|
||||
[ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md),
|
||||
[issue 124](../../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)):*
|
||||
where a provider **names the resource** it gives each consumer — a bucket, a database, a vhost — the
|
||||
name is derived per consumer, and a literal `serves` block could not carry it. A served value may
|
||||
now name the consumer the mesh is serving: `${consumer:as}`, the identity the mesh minted, and
|
||||
`${consumer:as:dns}`, that same identity written as a DNS label. Nothing else — **the mesh learns no
|
||||
protocol here; it spells its own name in an alphabet it already knows.** Settings are laid on first,
|
||||
so an operator may still set a prefix and the mesh derives the rest. The mesh fills it at the one
|
||||
moment it knows who the consumer is, and the one filled value reaches both ends: the consumer, as
|
||||
its binding's served facts and as `${bound:<provision>:<key>}` in any file it writes; the provider,
|
||||
as `derived` on that consumer's entry in its contributions file, so its provisioner is told the name
|
||||
rather than recomputing it. A consumer that writes the derived value into its own definition instead
|
||||
of asking for it is refused, naming the placeholder to use. *How it is checked:* the unit tests in
|
||||
ADR 0202's "how this is checked", each run against the unchanged controller first.
|
||||
|
||||
## How a definition reads what was resolved
|
||||
|
||||
**One form, naming a requirement and a field of its contract.** A definition that needs the database's
|
||||
@@ -214,7 +231,9 @@ name in a configuration file writes the same thing: the requirement's name and t
|
||||
controller fills it at resolution.
|
||||
|
||||
This one form replaces the placeholders that exist today, one per mechanism: bound values, secrets,
|
||||
ports and machine facts.
|
||||
ports and machine facts. It subsumes the consumer placeholder too — a value a provider derives is
|
||||
read by the consumer exactly as any other field of the contract is, and `${consumer:…}` is only
|
||||
how the *provider* states the rule.
|
||||
|
||||
**The seat placeholder stays, for the controller alone.** The controller composes its own
|
||||
declaration and reaches the store and broker it made before any module existed, so it cannot be
|
||||
|
||||
@@ -11,8 +11,10 @@ code:
|
||||
- mesh-host internal/apply/apply.go
|
||||
- mesh-tools src/main.ts
|
||||
- mesh-catalog modules/mesh-catalog
|
||||
updated: 2026-10-02
|
||||
- mesh-tools node-tools/internal/runtime (a module's state, ADR 0201)
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
|
||||
- 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
|
||||
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
|
||||
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
|
||||
@@ -67,6 +69,8 @@ the catalogue, and the mesh would have hundreds of copies of a decision it made
|
||||
| `tools: status` | queue-group subscription on `mesh.mod.<module>.tool.status` |
|
||||
| seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.accept.send` |
|
||||
| `uses: telegram-sender` | publish on that seat's `accept` subjects, and nothing else |
|
||||
| `state: servers` | a key-value bucket for the module, created by the controller; its instances write and read it |
|
||||
| `reads: billing.orders` | read and watch billing's `orders` bucket, and nothing else of it |
|
||||
|
||||
**Wildcards, and they are the mesh's rather than a bus's.** *Added 2026-09-27, from
|
||||
[issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md).* A
|
||||
@@ -221,7 +225,7 @@ service" versus "one worker per machine".
|
||||
| credential | sealed, per consumer | none | none | none | none |
|
||||
| reply | — | none | none, or an event later | a report | awaited |
|
||||
| retention | — | age and size | work queue, explicit ack | **last per subject** | none |
|
||||
| declared | `provides`/`requires` | `emits`/`consumes` | seat `accepts` / `uses` | the mesh's own | `serves` |
|
||||
| declared | `provides`/`requires` | `emits`/`consumes` | seat `accepts` / `uses` | the mesh's own; a module's `state` / `reads` | `serves` |
|
||||
|
||||
**Job** is the one [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) had no room
|
||||
for. Its table has events at 1:many and provisions at 1:1; a module submitting work to a service
|
||||
@@ -236,6 +240,31 @@ last-per-subject retention, and a node that has seen sequence *n* refuses *n−1
|
||||
That is the wire-level answer to
|
||||
[issue 107](../../04-ISSUES/107-a-declaration-carries-no-order/00-report.md).
|
||||
|
||||
**A module declares state too.** *Added 2026-10-04,
|
||||
[ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md).*
|
||||
State was the mesh's alone, and modules had the same need with nowhere to put it: an MCP server
|
||||
registered for every machine, sent as an event, never reached a machine assigned afterwards — its
|
||||
consumer did not exist yet when the event passed — and a licence binding sent as events replays a
|
||||
week of rotations where only the latest matters. So a module names the state it **owns** with
|
||||
`state`, and another module's it **reads** with `reads: <module>.<name>`. Each is a key-value bucket
|
||||
the controller creates from the catalogue, mesh-wide, existing from registration so a reader can
|
||||
watch before the owner runs anywhere ([design 25](25-the-bus-on-nats.md) §3). Every instance of the
|
||||
owner writes; a reader reads and watches. A key may name a machine by the module's own convention;
|
||||
the mesh keeps one bucket per name, not one per machine, because "every server, for every machine"
|
||||
is then one list rather than a walk.
|
||||
|
||||
What a module sees is what it named. Its assignment's membership lists its buckets by those names,
|
||||
with whether it may write, and the runtime answers `get`, `put`, `delete`, `keys` and `watch` for
|
||||
them on the bundle's channel — refusing, with the reason, a name it was not issued or a write to a
|
||||
bucket it only reads. A watch hands the current values first, then every change: a bundle that starts
|
||||
late, or starts again, has the whole of the state before it has any of the news.
|
||||
|
||||
The owner says how many past values a key keeps and how long a value lives, as a seat says how long
|
||||
its backlog survives (§3); the mesh caps a value's size and a bucket's. **A bucket outlives its
|
||||
module's assignment** — what a module stored is data
|
||||
([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md)) — and one whose
|
||||
declaration is gone is reported, never removed by the mesh.
|
||||
|
||||
## 5. Seats
|
||||
|
||||
A module declares a seat with its protocol, and the mesh enforces one holder at its scope
|
||||
@@ -454,6 +483,14 @@ sealing key leaks, that stream is an archive rather than a moment. So:
|
||||
existing discipline — *fetched from it, not carried* — applied to the one payload where carrying
|
||||
it is worst.
|
||||
|
||||
**A key-value bucket is a stream, so the same holds for it.** *Added 2026-10-04,
|
||||
[ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md).*
|
||||
No secret is put in a module's state, sealed or not: state is exactly what a machine joining a year
|
||||
later reads in full. A value that needs a secret names it, and the secret travels on request/reply.
|
||||
Sealed values are plain text to anything inspecting them, so this is checked only partly — the
|
||||
runtime refuses a value carrying a field whose name says it is a credential, which catches the
|
||||
ordinary mistake and not a determined one.
|
||||
|
||||
**The bootstrap, which is circular and has a precedent.** The vault makes every secret
|
||||
([ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md)), including the bus's own
|
||||
passwords. The vault is a module, and a module needs a bus account, whose password the vault
|
||||
@@ -523,3 +560,9 @@ billing existing under that name.
|
||||
on, and exactly those two are rebuilt.
|
||||
- **A stale declaration is refused.** A bed: replay sequence *n−1* after *n*, and the node refuses
|
||||
it rather than applying it.
|
||||
- **A module reaches only the state it declared.** The composer's test: an owner's runtime may write
|
||||
its buckets, a reader's may only read, and nothing else is granted; the runtime's test over a real
|
||||
bus: a name not issued and a reader's write are refused with the reason.
|
||||
- **State is current at once.** The runtime's test over a real bus: a watch hands the current values
|
||||
without deletions, then an end-of-current marker, then changes. Live: a machine assigned after a put
|
||||
reads it at start.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: designed
|
||||
code: []
|
||||
updated: 2026-10-02
|
||||
status: in-progress
|
||||
code: [mesh-catalog modules/claude-code]
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md
|
||||
- 02-DECISIONS/0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md
|
||||
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
|
||||
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
|
||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
||||
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||
@@ -48,14 +51,16 @@ instruction file and the manager's tools:
|
||||
|---|---|
|
||||
| `~/.claude/CLAUDE.md` | the managed instruction file: how a session on this mesh works (§3) |
|
||||
| `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md` | sections of the same file: this node's identity, the repositories' conventions |
|
||||
| `~/.claude/settings.json`, merged | the managed settings file: the mesh's keys only, outranking nothing a person did not also set |
|
||||
| `~/.claude/settings.json`, merged | the managed settings file: the mesh's keys, and the rules the operator set for the agent (§2) |
|
||||
| `~/.claude/skills/hal-switch-license/SKILL.md` | the manager seat's `switch` verb, listed by the console, and a sentence in the instruction file saying to use it |
|
||||
| `~/.claude/skills/cleanup/SKILL.md` | nothing; it named the predecessor's forge |
|
||||
| the console's entry in the agent's user-scope state | the managed settings' tool-server key, from the console's provision (§4) |
|
||||
|
||||
**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
|
||||
the module owns the directory `~/.claude` — that it exists, that the operator owns it, its mode,
|
||||
`0700` — and declares it, so the mesh refuses a second module owning it. Of what is inside, it owns only
|
||||
the agent's credentials file, which its own code writes for a subscription licence (§5); every other path
|
||||
is *found*. The person's memory, history, projects, local
|
||||
settings, their own rules, skills and tool servers are never read or written by the mesh. **The six
|
||||
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.
|
||||
@@ -63,16 +68,19 @@ lists them, and until they go the agent reads stale instructions beside the mesh
|
||||
## 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`.
|
||||
in that directory carrying the node's name and the console's endpoint, and a settings file carrying the
|
||||
role, the extra tool servers and the operator's managed-settings keys, merged from the module's settings layers — the bundle is told the two
|
||||
files' paths, because a bundle's words are paths and constants only (ADR 0192); the bus, the console's provision, and that it uses the `anthropic-licence-manager` seat.
|
||||
Two directories, declared so the ownership check sees them: the agent's managed directory under
|
||||
`/etc`, root's, and `~/.claude` under the operator's home, the operator's. No *file* resource under
|
||||
either: what is in them is written by the module's code (§2 below) or is the person's.
|
||||
|
||||
**Written by the module's code**, from the facts file and the manager's hand-over, whenever either
|
||||
changes:
|
||||
|
||||
| path | content |
|
||||
|---|---|
|
||||
| the managed settings file | the mesh's keys: the tool servers (the console, plus any the operator declared as settings), the attribution trailers, and — for an API-key binding only — the key-helper that serves the key |
|
||||
| the managed settings file | the keys the operator set in the module's `managed_settings` setting, with the mesh's keys laid over them: the tool servers (the console, plus any the operator declared as settings), the attribution trailers, and — for an API-key binding only — the key-helper that serves the key |
|
||||
| the managed instruction file | §3 |
|
||||
| the agent's credentials file under the operator's home | for a subscription binding only: the access token the manager handed over, as the operator, readable by the operator alone, atomic, no refresh token |
|
||||
| the module's keypair in its state | made once, the private half never leaves (§5) |
|
||||
@@ -87,6 +95,14 @@ requires. The model, the spinner, the drafts and every other preference are the
|
||||
predecessor's experience with the model key is the evidence: a mesh that sets a preference reverts a
|
||||
person's choice on every push.
|
||||
|
||||
**The operator's rules for the agent** ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)).
|
||||
What the agent may do without asking, what it must never do and what its unattended mode allows are
|
||||
not preferences: they are policy about an agent on the mesh, and a session must not loosen its own. The
|
||||
operator sets them in the module's `managed_settings` setting, in the vendor's settings shape, for the
|
||||
mesh or one node. The module copies those keys into the managed settings file and lays the mesh's keys
|
||||
last, so a setting can never replace the attribution convention, the connectors key or the key-helper,
|
||||
and a key-helper appears only for an API-key binding. The mesh still sets no preference by itself.
|
||||
|
||||
## 3. What the instruction file says
|
||||
|
||||
Prose, not a paste; the file is the module's.
|
||||
@@ -111,17 +127,20 @@ 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.
|
||||
> **Revised 2026-10-03, building it.** The vendor's managed-settings key for tool servers refuses any
|
||||
> URL that is not `https://`, including one on loopback, so it cannot carry the console. The module
|
||||
> owns the vendor's **exclusive** managed tool-server file instead (operator's choice): the console as
|
||||
> `mesh`, over HTTP on loopback, and every server in the module's `mcp_servers` setting — and no other.
|
||||
> A server added by hand, a project's own file and a plugin's servers stop loading; claude.ai's
|
||||
> connectors are kept by a managed setting. A person's own servers move into the setting, for the mesh
|
||||
> or for one node. Stdio straight to the bus, through the runtime's own client, was weighed and left
|
||||
> for later: it needs a verb the delivered runtime does not have, and a session holding its own bus
|
||||
> connection breaks on a credential rotation.
|
||||
|
||||
**Other tool servers** a person wants on every machine, or on one, are a declared setting of this module
|
||||
— 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
|
||||
— mesh layer or node layer — rendered into the same managed file. The person sets them with the
|
||||
controller's `settings` verb on this module, so the list stays declared state; the list is the operator's
|
||||
choice, set where every setting is set. The agent's own HTTP-only constraint for managed servers applies; a person's local
|
||||
command-based servers stay their own, in their own file.
|
||||
|
||||
**The entry's name is `mesh`.** The hand-made entry both workstations carry today is named after this
|
||||
@@ -131,36 +150,48 @@ it is the person's to remove, and until then the agent sees the mesh's tools twi
|
||||
## 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:
|
||||
decides it and [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md) says how it moves; to-be 39 is the manager's half. This module:
|
||||
|
||||
- **makes a keypair** in its state the first time it runs and registers the public half with the seat;
|
||||
- **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
|
||||
- **makes a keypair** in its state the first time it runs, and sends the public half with every request
|
||||
that is answered sealed;
|
||||
- **reports what the node holds**, as its own `holdings` state, one key for this node: the account's
|
||||
identity read from the agent's state file, the kind, the refresh token's fingerprint and whether one is
|
||||
present, the access token's fingerprint and expiry, the licence and generation it last applied, when the
|
||||
credentials file last changed. Written at start — a node already logged in reports at once — and on every
|
||||
change of the file. Never a token: the runtime refuses one anyway;
|
||||
- **hands over a grant only when asked**: `claude_code_grant` answers the manager, which gives its public
|
||||
key, with the full grant in the credentials file sealed to that key — the one time a refresh token
|
||||
leaves the node, for the manager to adopt by refreshing it;
|
||||
- **watches the manager's `bindings` state** for this node, and when the generation is newer than the one
|
||||
it applied, asks the seat's `current` verb for the token, sealed to its own key. A rotation of the same
|
||||
licence is applied only if newer within one lineage; a switch is applied regardless;
|
||||
- **writes** for a subscription licence the credentials file as the operator, **access-token-only** — so
|
||||
the agent here never refreshes, and a refresh token appearing later is a person's login, reported like
|
||||
any change; for the API-key licence sets the key-helper in the managed settings to a small program that
|
||||
prints the key from the module's state, so no file under the home is touched;
|
||||
- **adds an API key from this node** (*ADR 0209*): `claude_code_add_api_key` reads the key from a file
|
||||
here, seals it to the manager's `public-key`, hands it to the seat's `adopt`, removes the file once
|
||||
taken, and on request switches this node to the new licence;
|
||||
- **follows a login made here**: a login to another account is adopted and moves this node to it (ADR
|
||||
0209) — nothing for this module to do beyond reporting it;
|
||||
- **serves `claude_code_status`**: which licence and kind this node holds, when the token expires, whether
|
||||
the file matches what was handed over — by fingerprint, never by value.
|
||||
|
||||
Switching is the seat's `switch` verb, asked through the console; this module only applies what it is
|
||||
handed.
|
||||
Switching is the seat's `switch` verb, asked through the console; this module only applies what the
|
||||
state says it should hold. *2026-10-04:* this replaces the manager's visits of 2026-10-03 (ADR 0183's dated
|
||||
note): the node reports, the manager asks for a secret only when a report shows one it does not hold, and
|
||||
a token is fetched by request when the state says it changed.
|
||||
|
||||
## 6. Scope, settings and the order of assignment
|
||||
|
||||
**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.
|
||||
All four nodes carry one since 2026-10-03. **Per node:** the role. **Per mesh or per node:**
|
||||
extra tool servers, and the operator's managed-settings keys (§2). The controller's verb replaces a
|
||||
setting layer whole, so a layer set for one of these keeps the others it already held. **Prerequisite:** the manager holds its seat and has adopted the licences.
|
||||
|
||||
**Order:** the manager assigned and a refresh observed; the console's provision in the catalogue; this
|
||||
module on one workstation; the six predecessor files and the hand-made console entry removed there; a
|
||||
new session read to confirm it sees the mesh's instruction file, the console's tools under `mesh`, and
|
||||
new session read to confirm it sees the mesh's instruction file, the console's five tools under `mesh`, and
|
||||
its licence; then the rest.
|
||||
|
||||
## 7. The package
|
||||
@@ -178,13 +209,14 @@ installer is rejected: it puts a self-updating binary under the person's home, i
|
||||
|
||||
| Check | Defends |
|
||||
|---|---|
|
||||
| the module's definition names no node, path or login, declares nothing under a home or `/etc`, and no file resource carries a secret | ADR 0112, ADR 0155, ADR 0183 |
|
||||
| the module's definition names no node, path or login, declares no file under a home or `/etc` (only the two directories), and no file resource carries a secret | ADR 0112, ADR 0155, ADR 0183 |
|
||||
| on a lab machine with an account and a seeded home holding a person's rule file and the predecessor's leftovers: after assign, the managed directory holds the mesh's files, the home is byte-identical except the credentials file, which is owned by the operator and names no refresh token; after unassign, the managed directory's files are gone and the home is untouched | ADR 0182, the host's agnosticism |
|
||||
| on a lab machine with no account, the assignment is refused naming the fact | ADR 0181 |
|
||||
| a switch asked of the seat through the console changes the licence and the token on the node; no tool answer and no log line holds a token | ADR 0183 |
|
||||
| the API-key binding writes nothing under the home and the agent authenticates through the helper | ADR 0183 |
|
||||
| the module's test: keys set in `managed_settings` (an auto-mode allow list, a permissions list) appear in the rendered managed settings file, a setting naming the attribution, the connectors key or a key-helper is overridden, and a key-helper appears only for an API-key binding | ADR 0213 |
|
||||
| the console's provision resolves by co-location; a machine without the console refuses the module by name | ADR 0027, ADR 0152 |
|
||||
| a new session on the assigned workstation lists the console's tools under `mesh` and answers "which node am I" from the instruction file | the exit of the build |
|
||||
| a new session on the assigned workstation lists the console's five tools under `mesh` ([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)) and answers "which node am I" from the instruction file | the exit of the build |
|
||||
|
||||
## What this does not settle
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [mesh-host, mesh-controller, mesh-tools, mesh-catalog]
|
||||
updated: 2026-10-02
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
|
||||
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
|
||||
@@ -33,12 +33,16 @@ This is the design [to-be 29](29-a-node-has-operator-accounts.md) §2 called *a
|
||||
Worked on the first one, a shell. The `zsh` module declares:
|
||||
|
||||
- a **package**, `zsh`;
|
||||
- **files under the home**, owned by the account: the shell's rc file with the module's default
|
||||
configuration, carrying a kept region for the operator's own lines, and `${setting:…}`
|
||||
placeholders for the few values a node varies; the account and its home are machine facts the
|
||||
controller resolves ([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md),
|
||||
to-be 29 §2);
|
||||
- a **seat declaration**, `login-shell`, node-scoped, with its one verb; and a **claim** on it;
|
||||
- **files under the home**, owned by the account: the mesh's block at the start of the shell's rc
|
||||
file with the module's default configuration and the slots other modules' code lands in, the
|
||||
operator's own lines kept after it, and `${setting:…}` placeholders for the few values a node
|
||||
varies; the account and its home are machine facts the controller resolves
|
||||
([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md),
|
||||
to-be 29 §2). Its environment is a contribution to the environment module, not lines of its own
|
||||
([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
|
||||
[ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md),
|
||||
[to-be 41](41-the-shell-and-the-accounts-environment.md));
|
||||
- a **claim** on the mesh's node-scoped seat `node-login-shell`, with its one verb;
|
||||
- a **`user` shape** naming the shell, applied only where the module holds the seat;
|
||||
- a **tools bundle**, the artifact kind for interpreted code, with `execute` and the module's own
|
||||
`show-config`.
|
||||
@@ -80,7 +84,8 @@ root escalates itself.
|
||||
|
||||
## 4. The seats of the environment
|
||||
|
||||
Decided now: **`login-shell`** (module-declared; zsh, fish, bash; verb `execute`) and
|
||||
Decided now: **`node-login-shell`** (the mesh's own, ADR 0204; zsh, fish, bash; verb `execute`),
|
||||
**`node-environment`** (the mesh's own, ADR 0203; the environment module; no verbs) and
|
||||
**`node-service-manager`** (the mesh's own; systemd; verbs over units in both scopes). The rest
|
||||
are candidates from [research 018](../../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md),
|
||||
one record each when its first holder is written: display server, display session, terminal
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [mesh-tools, mesh-controller, mesh-host, mesh-catalog]
|
||||
updated: 2026-10-03
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
|
||||
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
|
||||
@@ -15,6 +15,7 @@ decisions:
|
||||
- 02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md
|
||||
- 02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md
|
||||
- 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md
|
||||
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
|
||||
---
|
||||
|
||||
# 38. Building the operator's machine
|
||||
@@ -309,8 +310,34 @@ nextcloud, minio), the two whose clients exist on no system (mongodb, mssql: a d
|
||||
and last the mesh's own (mesh-catalog, mesh-vault, records, gitea, mailu, audit-logger, lab, and the
|
||||
three mains).
|
||||
|
||||
*Built 2026-10-04.* Thirty-four modules no longer run their own code in a container: the first wave
|
||||
(mesh-catalog#245), the mesh's own and the media modules (mesh-catalog#248, mesh-media-catalog#13),
|
||||
with a step run where and as it is declared (mesh-host#85) and a process's words filled like a
|
||||
container's (mesh-controller#250). **Proven live** on every machine that runs them: each moved
|
||||
module's tools answer from the node's runtime, the steps run as their oneshot units, and the forge's
|
||||
merge events reach the build pipeline from the runtime — the merge after the move started its own
|
||||
plan. **Two corrections the machines taught:** a tool that called a broker's command-line client now
|
||||
runs it inside the broker's own container, because a host package may be uninstallable on a machine
|
||||
whose package index is stale (mesh-catalog#249); and a module reading its application's own key reads
|
||||
it through the application's container, because that directory belongs to the account the application
|
||||
runs as there, which is not the runtime's (mesh-media-catalog#14). *Completed 2026-10-04:* the last three — mesh-catalog, mongodb and mssql, whose code imports npm
|
||||
packages of its own — moved once the builder installs a bundle's own dependencies before compiling,
|
||||
keeping the toolchain's SDK authoritative (mesh-controller#255, mesh-catalog#250). Their database
|
||||
clients are now drivers inlined into the bundle, not command-line clients fetched by a container; one
|
||||
more correction the machines taught: a driver reaching its server on loopback must give TLS a host
|
||||
name, since the runtime's Node refuses an address (mesh-catalog#252). **No module's own code runs in
|
||||
a container any more;** every module with tools answers from its node's runtime, proven by calling a
|
||||
tool of each.
|
||||
|
||||
## WP5 — The shell, on a server first
|
||||
|
||||
*Replaced on 2026-10-04 by [to-be 41](41-the-shell-and-the-accounts-environment.md).* A review before
|
||||
assigning found that the shell module would duplicate every machine's existing startup file, drop
|
||||
lines from it, leave the prompt uninstalled, and could not be unassigned
|
||||
([issue 228](../../04-ISSUES/228-a-login-the-mesh-set-is-never-given-back/00-report.md)). The shell,
|
||||
its environment, the modules that plug into it, and the host's fix are built and proven there. What
|
||||
follows is the original plan, kept for the record.
|
||||
|
||||
*mesh-catalog #224, already written. Half a day to assign and prove.*
|
||||
|
||||
**Order.** Assign `zsh` to one server; push; `login-shell.execute@<server> command="uptime"`
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: designed
|
||||
code: []
|
||||
updated: 2026-10-02
|
||||
status: implemented
|
||||
code: [mesh-catalog modules/claude-licence-manager]
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md
|
||||
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
|
||||
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||
- 02-DECISIONS/0024-model-access-is-a-provision.md
|
||||
- 02-DECISIONS/0050-model-access-is-vendor-agnostic.md
|
||||
@@ -74,22 +76,22 @@ Carried from the predecessor, where each rule was earned by an incident:
|
||||
|
||||
## 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:
|
||||
*Amended 2026-10-04 by [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)*, replacing the manager's visits: **what each consumer
|
||||
should hold is the manager's state, and the token is fetched when it changes.**
|
||||
|
||||
- **On rotation**, the manager calls `claude-code.apply@<node>` on every node bound to the rotated
|
||||
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.
|
||||
- **The manager keeps a `bindings` state**, one key per consumer: the licence, its kind, and a
|
||||
**generation** that increases with every rotation and every switch. Nothing in it is secret.
|
||||
- **The agent module on each node watches its own key.** When the generation is newer than the one it
|
||||
applied, it asks the seat's `current` verb, sending its public key, and is answered with the token
|
||||
sealed to it — request/reply, never an event. A node that was away reads its key when it is back and
|
||||
asks once; a manager that is down leaves every node on its last token, which lives hours.
|
||||
- **On a switch** the agent applies the new licence's token without comparing expiries, because across
|
||||
two licences the numbers are unrelated; within one licence it applies only a newer grant.
|
||||
- **No event announces a rotation or a switch.** What they announced is the state itself, and a node
|
||||
needs the latest, not the history. What the manager still emits names an outcome and carries no token.
|
||||
|
||||
A node whose module has not registered a key cannot be handed a token, and the manager says so by name
|
||||
rather than falling silent. A node whose module refuses — a wrong identity, a stale grant within one
|
||||
lineage — is recorded as drift and reported.
|
||||
A consumer that never asks is visible: its own report (§6) names the licence and generation it holds,
|
||||
and a node behind its binding is drift the manager reports.
|
||||
|
||||
## 5. Who gets which licence
|
||||
|
||||
@@ -116,27 +118,51 @@ 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:
|
||||
*Amended 2026-10-04 by [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)*: a licence is an account, learned from what the nodes
|
||||
report, and adopted by refreshing it.
|
||||
|
||||
- **From a node's login.** A person logs in on a node, as they always have. The agent module there reads
|
||||
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.
|
||||
- **Every node reports what it holds**, as the agent module's `holdings` state, one key per node: the
|
||||
account's identity read from the agent's own state file, the kind, the refresh token's fingerprint and
|
||||
whether one is present, the access token's fingerprint and expiry, the licence and generation it was
|
||||
last handed, when the credentials file last changed. Written when the module starts — a node already
|
||||
logged in reports at once — and on every change. Never a token.
|
||||
- **The manager reads every report at start and watches them.** A report with a refresh token whose
|
||||
fingerprint the manager does not hold is a candidate: a new licence for an account it has none for, a
|
||||
login made since for one it has. A manager launched for the first time holds no licence and takes every
|
||||
report as a candidate.
|
||||
- **The secret is asked for, never published.** For a candidate the manager calls that node's agent
|
||||
module, giving its own public key, and is answered with the grant sealed to it.
|
||||
- **Adopting is refreshing.** The manager exchanges the candidate's refresh token under its lease for
|
||||
that account; success makes the returned grant the licence's and the manager its only rotation source;
|
||||
failure records the candidate dead and adopts nothing. Candidates for one account are tried newest login
|
||||
first, and the first that refreshes ends the search — the others are never exchanged.
|
||||
- **The latest login wins.** A bound node is handed an access token only and its file holds no refresh
|
||||
token, so a refresh token appearing there later is a person's login; its report makes it a candidate,
|
||||
and if it refreshes it replaces the licence's grant.
|
||||
- **A login moves its node** (*amended 2026-10-04 by [ADR 0209](../../02-DECISIONS/0209-a-login-on-a-node-moves-that-node-to-its-account-and-an-api-key-is-added-from-any-node-sealed.md)*): the node a login was
|
||||
adopted from is bound to that login's licence — switched, if it was bound to another — and every node
|
||||
bound to nothing whose report names an account the manager holds is bound to it. `bind`, `switch` and
|
||||
`release` move a node without a login.
|
||||
- **The identity guard** files a grant under the identity the node read; where the vendor's refresh
|
||||
answer names the account too, a mismatch is refused and notified. Which source decided is audited.
|
||||
- **An API key** enters from any node (*ADR 0209*): the agent module there reads it from a file on its own
|
||||
node, seals it to the manager's key (the seat's `public-key` verb) and hands it to `adopt`, removing the
|
||||
file once taken — or `adopt` reads a file on the manager's node. Never an argument, never on a stream.
|
||||
An API key is a licence of its own and moves a node only through `bind` or `switch`.
|
||||
|
||||
## 7. What it emits and serves
|
||||
|
||||
**Events**, no secret in any: `licence.rotated`, `licence.switched`, `licence.adopted`,
|
||||
`licence.failing`, `licence.refused`, `usage.read` — the audit logger records them all.
|
||||
**Events**, no secret in any: `licence.adopted`, `licence.failing`, `licence.refused`, `usage.read` —
|
||||
the audit logger records them all. *2026-10-04 (ADR 0206):* `licence.rotated` and `licence.switched` are
|
||||
gone; a rotation or a switch is a new generation in the `bindings` state.
|
||||
|
||||
**State**: `bindings`, which it keeps; the agent module's `holdings`, which it reads.
|
||||
|
||||
**The seat's verbs**, the contract every future holder must serve: `licences` (each with kind,
|
||||
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).
|
||||
or all), `usage` (current and history), `adopt` (a file on the manager's node, or a key sealed to its
|
||||
`public-key` — ADR 0209), `public-key`, and `current` (a consumer's token, sealed to the key the
|
||||
consumer sends — ADR 0206). The manager asks a node for a candidate grant by the agent module's own tool.
|
||||
|
||||
## 8. Settings
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: implemented
|
||||
code: [mesh-catalog modules/claude-code, mesh-catalog modules/claude-licence-manager]
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
|
||||
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
|
||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
||||
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
|
||||
- 02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md
|
||||
- 02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md
|
||||
- 02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md
|
||||
- 02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md
|
||||
---
|
||||
|
||||
# 40. Building the operator's agent and its licence manager
|
||||
|
||||
**The work of [design 36](36-the-operators-agent-on-a-machine.md) and [design 39](39-the-anthropic-licence-manager.md),
|
||||
broken into packages that each end at something a person can see run, in the order their
|
||||
dependencies allow.** The two designs are the authority on *what* is built; this document holds the
|
||||
packages, their order, their sizes and their proofs, and is wrong the moment it disagrees with them.
|
||||
It is the shape [design 38](38-building-the-operators-machine.md) gives the operator's machine.
|
||||
|
||||
*Revised 2026-10-03, after design 38's WP1–WP4b ran:* the node's tool runtime is live on all four
|
||||
machines, tools are bundles it serves and each is given only the words its artifact declares, every
|
||||
bundle is a child the runtime launches over stdio and is the bus for
|
||||
([ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md),
|
||||
[ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)),
|
||||
and the console offers five tools over addresses
|
||||
([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)). What changed
|
||||
in this plan: the wait on design 38's WP3 is over; the manager starts every exchange, by the operator's
|
||||
direction (ADR 0183's dated note); and the manager's daemon is a long-running bundle the runtime launches,
|
||||
which ADR 0198 decided the same day — nothing in this plan waits on another record.
|
||||
|
||||
## How this is built, and where it is run
|
||||
|
||||
**On the live mesh, by the operator's decision** ([ADR 0149](../../02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md)).
|
||||
Every package is written with unit tests, committed on one branch per repository
|
||||
([playbook 07](../../00-META/process/07-feature-branches.md)), and proven on the machines: one
|
||||
workstation first for the agent, the control node first for the manager, then the rest. A broken agent
|
||||
module leaves a workstation's agent without the mesh's instructions or with a stale token until the next
|
||||
push; the person's own files under the home are out of the failure's reach, by
|
||||
[ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md).
|
||||
|
||||
Each package names what proves it. A package that cannot name its proof is divided until it can.
|
||||
|
||||
## What exists already, measured
|
||||
|
||||
Measured 2026-10-03 on the four machines and in the repositories.
|
||||
|
||||
| Piece | Today | Becomes |
|
||||
|---|---|---|
|
||||
| the node's tool runtime | live on all four, a host process; **runs as the operator account**; listens for the console on loopback at a port its own code fixes; launches or imports every assigned module's tools bundle and hands each its declared words | serves the agent module's tools; gains one provision for its endpoint (WP1) |
|
||||
| the operator account | **stated on all four** — the runtime runs as it | read by the agent module from the runtime's own words |
|
||||
| escalation | passwordless `sudo` for the operator account on all four — a fact about the machines, checked by nobody | how the agent module writes its managed directory under `/etc` |
|
||||
| the agent itself | installed on all four, at four different versions, all above the one the managed tool-server key needs | declared as the module's package |
|
||||
| a bundle's words | paths and constants written with `${dir:…}` and `${port:…}` only; a fact the mesh knows reaches a bundle as a file whose path is a word | the agent module's facts file and settings file |
|
||||
| a bundle calling a tool | `mesh/ask` through the runtime that launched it (ADR 0198); no bundle holds a bus credential | how the manager visits every node |
|
||||
| a module's own long-running code | a bundle the runtime launches and restarts (ADR 0198); the runtime's subscription and grants built, the modules moving in design 38 WP4c's waves | the manager's daemon (WP3, WP4) |
|
||||
| the vendor's refresh, the sealed box, the grant file | `anthropic-manager` in the catalogue, built on the controller placement ADR 0183 moved away from; assigned to nothing | its client ported into the manager; the module retired (WP6) |
|
||||
| the credentials write, the strip, the identity read | `anthropic-consumer` in the catalogue; tested; assigned to nothing | ported into the agent module with its tests; the module retired (WP6) |
|
||||
| the predecessor's manager and consumer | the lease per licence, the expiry floor, the lineage comparison, the identity guard, three touchpoints, cooldowns | ported as logic with its tests |
|
||||
|
||||
## The order the work allows
|
||||
|
||||
```
|
||||
WP0 the operator names the licences and each node's role (the live mesh) ── an hour
|
||||
WP1 the runtime provides its endpoint (mesh-tools) ── small
|
||||
WP2 the agent module (mesh-catalog) ──┐ WP2 needs WP1;
|
||||
WP3 the manager's code, built and tested (mesh-catalog) ──┘ WP3 is independent
|
||||
│
|
||||
WP2 live: one workstation, configuration only — no licence yet ── the first live proof
|
||||
│
|
||||
WP4 the manager live on the control node ── its daemon a long-running bundle (ADR 0198)
|
||||
WP5 the licence end to end on one workstation
|
||||
WP6 the rest of the nodes, and the predecessor's remains
|
||||
```
|
||||
|
||||
## WP0 — The operator names the licences and each node's role
|
||||
|
||||
*The live mesh. An hour, and it is the operator's.* The accounts are stated already. What remains: the
|
||||
names of the two subscription licences and the API key; each node's role, as the agent module's setting
|
||||
on the node layer once the module is registered.
|
||||
|
||||
**Proof.** The module's settings list a role for every node; the licences have names.
|
||||
|
||||
## WP1 — The runtime provides its endpoint
|
||||
|
||||
*mesh-tools. An hour.*
|
||||
|
||||
**What changes.** The `node-tools` manifest provides a node-scoped provision, `mcp-endpoint`, serving
|
||||
the port its code listens on, the way the local model server serves its API
|
||||
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)). Co-location
|
||||
resolves it. The runtime's port stays what its code fixes; assigning it is
|
||||
[issue 192](../../04-ISSUES/192-the-meshs-tools-reach-a-person-only-by-a-registration-made-by-hand/00-report.md)'s
|
||||
second question and not this package's.
|
||||
|
||||
**Proof.** The plan for a workstation carrying a consumer of `mcp-endpoint` shows it bound to the
|
||||
runtime's port; the controller's tests and the catalogue's checks pass.
|
||||
|
||||
## WP2 — The agent module
|
||||
|
||||
*mesh-catalog. A day and a half.*
|
||||
|
||||
**What is written**, as design 36 says:
|
||||
|
||||
1. **The manifest.** The agent's package. A state directory. A facts file in it, rendered by the mesh:
|
||||
the node's name and the console's address from `mcp-endpoint`. A settings file in it, merged from
|
||||
the module's settings layers: the node's role and the extra tool servers. A tools bundle whose
|
||||
words name the two files, the state directory and nothing else. The two directories it owns declared — the agent's managed
|
||||
directory and `~/.claude` — and **no file resource under either.**
|
||||
2. **The renderer**, run whenever the runtime collects the module's tools: from the two files, the
|
||||
managed settings file (the tool servers under the entry `mesh`, the attribution trailers, the
|
||||
key-helper for an API-key binding) and the managed instruction file, written under the agent's
|
||||
managed directory through the account's escalation, only when their content changed.
|
||||
3. **The keypair**, made once in the state directory; X25519 and an authenticated cipher from the
|
||||
language's own library, so the bundle carries no dependency.
|
||||
4. **The tools and the state** (*2026-10-04, ADR 0206*): `claude_code_status` (what is rendered, what
|
||||
licence is held, when its token expires, fingerprints only); `claude_code_render` (render now);
|
||||
`claude_code_grant` (the full grant in the credentials file, sealed to the key the manager gives).
|
||||
The `holdings` state, written at start and on every change of the credentials file; a watch of the
|
||||
manager's `bindings` key for this node, which asks the seat's `current` on a newer generation and
|
||||
applies the sealed answer — only if newer within one lineage unless it is a switch; the credentials
|
||||
write as the operator, access-token-only, atomic; the key-helper program for an API key.
|
||||
5. **The documentation**: the six predecessor files and the hand-made console entry a person removes.
|
||||
|
||||
**Proof, before anything runs live.** Unit tests: the renderer writes the mesh's keys and nothing else;
|
||||
it writes nothing when nothing changed; the credentials write strips a refresh token and is atomic; the
|
||||
lineage cases from the predecessor; a sealed hand-over opens only with the module's key; no tool's answer
|
||||
contains a token. The catalogue's checks pass.
|
||||
|
||||
**Proof, live, on one workstation, configuration only.** Assign the module; set the node's role; push.
|
||||
The agent's managed directory holds the two files; everything under the person's agent directory is
|
||||
byte-identical to before; a new session lists the console's five tools under `mesh` and answers *which node am
|
||||
I* from the managed instruction file. `claude_code_status` answers through the console. No licence is
|
||||
touched: the module writes the credentials file only when it is handed a token.
|
||||
|
||||
## WP3 — The manager's code, built and tested
|
||||
|
||||
*mesh-catalog. Two to three days.*
|
||||
|
||||
**What is written**, as design 39 says: the manifest (the seat and its verbs, a database, a `secret`
|
||||
for the key the grants are encrypted with, a tools bundle and a long-running bundle for the daemon, both launched by the runtime, settings
|
||||
with defaults); the store's migrations; the refresh with its plan, lease, floor and cadence as pure
|
||||
functions; the vendor client from `anthropic-manager`; adoption from a file and from a node's waiting
|
||||
login with the identity guard; usage and its threshold; the seat's verbs. *2026-10-04 (ADR 0206):* in place
|
||||
of the visit, the watch of every node's `holdings`, adoption of a candidate by refreshing it (newest login
|
||||
first, once per account), the `bindings` state with a generation per consumer, and `current`.
|
||||
|
||||
**Proof, before anything runs live.** Unit tests: two refresh runs started together rotate one grant
|
||||
once; a mismatching identity is refused; a worker bound to a dead licence is refused and never lent
|
||||
another; nothing the daemon emits carries a token; a hand-over sealed for one node opens with no other
|
||||
node's key.
|
||||
|
||||
## WP4 — The manager live on the control node
|
||||
|
||||
*The live mesh. Half a day.* The daemon is a long-running bundle the control node's runtime launches
|
||||
(ADR 0198); it calls each node's agent module by `mesh/ask`. If the runtime's half of ADR 0198 is not
|
||||
yet live on the control node when this package starts, this package waits for it: no tool container, no
|
||||
credential copied by hand.
|
||||
|
||||
**Order.** Assign the manager on the control node; push. It reads every node's `holdings` and adopts each
|
||||
account the nodes are logged in to, by refreshing the newest login's grant (ADR 0206); each node with no
|
||||
binding is bound to the account it reported. Adopt the API key from a file there. A second subscription
|
||||
account enters by a login on a workstation carrying the agent module.
|
||||
|
||||
**Proof.** Through the console: `anthropic-licence-manager.licences` lists three licences with identity
|
||||
and expiry; within the cadence the audit shows a rotation and a later expiry; a forced `refresh` is
|
||||
logged with the vendor's answer.
|
||||
|
||||
## WP5 — The licence end to end on one workstation
|
||||
|
||||
*The live mesh. Half a day. The proof of the whole.*
|
||||
|
||||
**Order.** Record the checksums under the person's agent directory. Bind the workstation to a
|
||||
subscription licence. Remove the six predecessor files and the hand-made console entry. Start a session.
|
||||
|
||||
**Proof.** Everything under the person's agent directory is byte-identical but the credentials file,
|
||||
which is owned by the operator, readable by nobody else, and names no refresh token. A session makes a
|
||||
model request. `switch` to the second subscription licence changes the token on the workstation within a
|
||||
minute, and neither the verb's answer nor either module's log holds a token. Switched to the API key, the
|
||||
credentials file is left as it was and the agent authenticates through the key-helper. Switched back.
|
||||
|
||||
## WP6 — The rest of the nodes, and the predecessor's remains
|
||||
|
||||
*The live mesh and mesh-catalog. One day.* The module on every node, the predecessor's files removed on
|
||||
the second workstation; a login under a licence's account collected and adopted, and one under the wrong
|
||||
account refused and notified; `anthropic-manager` and `anthropic-consumer` retired from the catalogue;
|
||||
designs 36 and 39 set to `implemented` with the as-is written
|
||||
([playbook 02](../../00-META/process/02-graduation.md)).
|
||||
|
||||
**Proof.** `claude_code_status` answers on every node; the refusal's notification arrived; the catalogue
|
||||
has no module built on the old placement.
|
||||
|
||||
## What is deliberately not here
|
||||
|
||||
- **The package repository seat** for a distribution that does not carry the agent's package (design 36
|
||||
§7). The four machines have the package; a fifth would refuse the module in its package manager's
|
||||
words.
|
||||
- **Escalation as a checked fact.** The agent module's write under `/etc` relies on the operator
|
||||
account's passwordless `sudo`, true on all four and checked by nothing. A machine without it refuses
|
||||
the render in the tool's own words; making escalation a reported capability is design 38's to decide.
|
||||
- **An automated switch on exhaustion.** The readings are kept from WP4; the policy is a later record.
|
||||
- **Workers and the mesh's own sessions as consumers.** The manager knows them from WP3; the consumers do
|
||||
not exist yet ([to-be 15](15-the-agent-session.md)).
|
||||
- **Whether a refresh token is single-use.** WP4 may measure it; the design holds either way.
|
||||
|
||||
## How this list is kept true
|
||||
|
||||
Each package's proof is run when the package is finished and its line here gains the date and the
|
||||
commit, as design 38 does. A package whose proof fails is not reworded; the failure is recorded under it
|
||||
and the package stays open. When WP2's live proof runs, design 36 moves to `in-progress` with its owning
|
||||
repository; when WP5's does, design 39 does too; and when WP6's does, both move to `implemented`, with
|
||||
the as-is written.
|
||||
@@ -0,0 +1,238 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [mesh-host, mesh-controller, mesh-catalog]
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
|
||||
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
|
||||
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
|
||||
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
|
||||
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
|
||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
||||
---
|
||||
|
||||
# 41. The shell and the account's environment
|
||||
|
||||
What it takes for the operator's shell to be modules, without a machine losing anything it does
|
||||
today. This design replaces the shell half of
|
||||
[to-be 38](38-building-the-operators-machine.md) WP5, and finishes the service-manager module of WP6
|
||||
short of its user-scoped units. The decisions are [ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)
|
||||
(the environment), [ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
|
||||
(shell code and the seat) and [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md)
|
||||
(vendored software). The evidence is [research 025](../../01-RESEARCH/025-how-a-module-plugs-into-the-shell/00-overview.md).
|
||||
|
||||
## What a node with a shell looks like
|
||||
|
||||
```
|
||||
toolchain, agent, … prompt, plugins, version manager
|
||||
│ environment │ shell (zsh, slot)
|
||||
▼ ▼
|
||||
node-environment ── holds ── node-env node-login-shell ── holds ── zsh
|
||||
│ │
|
||||
├─▶ ~/.config/mesh/environment.sh ◀─ sourced from zsh's block in ~/.zshenv
|
||||
└─▶ ~/.config/environment.d/50-mesh.conf ◀─ read by the account's service manager
|
||||
│
|
||||
~/.zshrc: the mesh's block FIRST — defaults and the
|
||||
three slots — then the operator's own lines, kept
|
||||
```
|
||||
|
||||
**The environment module** (`node-env`) holds `node-environment`. It has no package and no process.
|
||||
Its two files are written by the host from placeholders the controller fills.
|
||||
|
||||
**The shell module** (`zsh`) holds `node-login-shell`. It:
|
||||
|
||||
- installs the package;
|
||||
- sets the login shell through the `user` shape;
|
||||
- writes two blocks:
|
||||
- one in `.zshenv`, sourcing the environment;
|
||||
- one at the start of `.zshrc`, holding the defaults every machine shares today: the title, the
|
||||
keybindings, the aliases and the two small functions, with the three slots in place;
|
||||
- contributes its own environment: the editor, the configuration home, and `~/.local/bin` plus the
|
||||
two script directories on `PATH`;
|
||||
- serves `execute` and its own `zsh_config`.
|
||||
|
||||
**The prompt module** (`powerlevel10k`) ships the theme as a pinned vendored archive, and the prompt's
|
||||
configuration as its own file in a directory it owns. It contributes the zsh code that loads both.
|
||||
|
||||
**Two plugin modules** (`zsh-autosuggestions`, `zsh-syntax-highlighting`) each install their
|
||||
distribution package and contribute one line. Syntax highlighting goes in the `last` slot, which is
|
||||
what its upstream asks for.
|
||||
|
||||
**What stays the operator's** is everything below the block in `.zshrc`, and `~/.zshrc.local`, which
|
||||
the operator's lines source as they do today. On every machine today that means:
|
||||
|
||||
- the version manager's lines and the toolchain's `PATH` entry, until those modules exist;
|
||||
- the two variables naming the operator's own script library;
|
||||
- the agent's title variable;
|
||||
- the port aliases;
|
||||
- the workstation's desktop variables, which live in `~/.zshrc.local` already.
|
||||
|
||||
Nothing is lost at any step, because a line moves out of the operator's part only when a module
|
||||
carries it.
|
||||
|
||||
**The migration is a person's act**, listed in the zsh module's documentation (ADR 0182): after the
|
||||
first push, delete from `.zshrc` the lines the block now carries. Until then they run twice, which is
|
||||
harmless and visible.
|
||||
|
||||
## Work packages
|
||||
|
||||
```
|
||||
WP1 the host gives a login back (mesh-host) issue 228
|
||||
WP2 the controller composes environment and shell code (mesh-controller)
|
||||
WP3 the modules (mesh-catalog) needs WP2 to resolve
|
||||
WP4 the service manager's module, finished (mesh-catalog) independent
|
||||
WP5 assign and prove (operator-gated) needs WP1–WP3 merged and rolled
|
||||
```
|
||||
|
||||
WP1, WP2 and WP4 are independent, and are built in parallel on one feature branch per repository
|
||||
([playbook 07](../../00-META/process/07-feature-branches.md)). WP3 is written in parallel and proven
|
||||
against WP2's controller before anything is published.
|
||||
|
||||
## WP1 — The host gives a login back
|
||||
|
||||
*mesh-host. Half a day. [Issue 228](../../04-ISSUES/228-a-login-the-mesh-set-is-never-given-back/00-report.md).*
|
||||
|
||||
**What changes.**
|
||||
|
||||
- The `user` shape records, in its applied record, the login shell it found whenever it changes it.
|
||||
- Removing a `user` never deletes the account, whether or not the mesh created it. If the account's
|
||||
shell is still the one the mesh set, and the recorded shell is still executable, the recorded shell
|
||||
is set back. Otherwise the shell is left as it is, and the outcome says why.
|
||||
- Before a shell is set, it is refused unless it is executable and listed among the machine's shells.
|
||||
The exception is a shell that refuses logins (`nologin`, `false`): the distribution does not list
|
||||
those, and the controller's own account uses one, so it need only be executable. The refusal fails
|
||||
that resource and leaves the account untouched.
|
||||
- A directory the host creates on the way to a file, a block or an archive inside an account's home
|
||||
belongs to that account, the home itself included when the host makes it. A directory that was
|
||||
already there keeps its owner and mode (ADR 0182). Until this, a fresh account's `~/.config` or
|
||||
`~/.local/share` would have been created as root's.
|
||||
- Giving the shell back is reported, never fatal. A failed `usermod` on removal is named in the
|
||||
outcome and the record is dropped, because a fatal removal is exactly the wedge issue 228 is about.
|
||||
|
||||
**Proof.** The host's tests:
|
||||
|
||||
- an undeclared `user` no longer stops the apply;
|
||||
- the found shell comes back;
|
||||
- a shell changed by a person since is left alone;
|
||||
- a missing shell is refused before `usermod` runs;
|
||||
- a created account survives its removal.
|
||||
|
||||
## WP2 — The controller composes environment and shell code
|
||||
|
||||
*mesh-controller. One to two days.*
|
||||
|
||||
**What changes.**
|
||||
|
||||
- **The seat table.** It gains `node-environment` (node scope, no verbs) and `node-login-shell`
|
||||
(node scope, the verb `execute`). A module may no longer declare a seat named `login-shell` or
|
||||
`node-login-shell`. Both new seats are seeded into a live store by the existing additive seeding.
|
||||
- **The manifest.** It gains two contribution fields, each refused at parse when malformed:
|
||||
- `environment`, with `variables` and `path`: a variable name must be a POSIX name and not `PATH`;
|
||||
a value may not contain `$`, a quote, a backslash or a line break; a path entry's place is
|
||||
`start` or `end`;
|
||||
- `shell`: each entry names a known shell, a known slot, and non-empty code.
|
||||
- **Composition.** It fills `${environment:posix}`, `${environment:systemd}` and
|
||||
`${shell:<shell>:<slot>}` in the claiming holder's file contents, from every module assigned to
|
||||
the node. The rendering is ADR 0203's and ADR 0204's: module order, a naming line per contribution,
|
||||
`PATH` entries added only when missing. `${machine:…}` in a contributed value is resolved first.
|
||||
- **Refusals.** A variable set by two modules on one node is refused, naming both. A placeholder in a
|
||||
module that does not claim the matching seat is refused, both at the catalogue check and at
|
||||
composition.
|
||||
|
||||
**Proof.** The controller's tests:
|
||||
|
||||
- both environment renderings, byte for byte, from a fixed set of contributions;
|
||||
- the POSIX rendering sourced twice by `sh` leaves `PATH` unchanged;
|
||||
- slot order and per-shell filtering;
|
||||
- each refusal, by name;
|
||||
- the seat table carries both seats and refuses a module declaring either.
|
||||
|
||||
The catalogue check over the whole catalogue passes.
|
||||
|
||||
## WP3 — The modules
|
||||
|
||||
*mesh-catalog. One day.*
|
||||
|
||||
**What changes.**
|
||||
|
||||
- **`node-env`, new.** It claims `node-environment` and declares two owned files: the POSIX file at
|
||||
the path the seat fixes, and the service manager's file, each holding its placeholder. It declares
|
||||
no tools.
|
||||
- **`zsh`, rewritten.**
|
||||
- It drops its seat declaration and claims `node-login-shell`.
|
||||
- Its environment moves to a contribution.
|
||||
- It writes a `.zshenv` block that sources the environment file.
|
||||
- Its `.zshrc` block goes at the start and carries today's shared defaults, with the three slots.
|
||||
- It keeps the `user` shape.
|
||||
- `execute` runs `zsh -lc` in the account's home, with the runtime's session words for the user
|
||||
manager. Its timeout is bounded below the runtime's thirty-second call limit; its output is cut at
|
||||
a bound and marked as cut; on timeout it kills the process group.
|
||||
- Tests cover the tool over real child processes and the manifest's shape.
|
||||
- Its documentation lists the one-off migration.
|
||||
- **`powerlevel10k`, new.**
|
||||
- The theme is vendored at a pinned upstream release, with its licence, as an archive artifact
|
||||
unpacked into the module's directory under the account's home.
|
||||
- The prompt configuration is today's file, as its own owned file in the same directory.
|
||||
- It contributes the zsh code that loads the theme and the configuration.
|
||||
- Today's file has the instant-prompt cache commented out, so the module does not turn it on.
|
||||
- **`zsh-autosuggestions` and `zsh-syntax-highlighting`, new.** Each declares its package and
|
||||
contributes its loader from the distribution's path, in the `normal` and `last` slots.
|
||||
|
||||
**Proof.** The controller's catalogue check over the whole catalogue passes. The modules' tests pass.
|
||||
A rehearsal composition for a node holding all five shows:
|
||||
|
||||
- the `.zshrc` block with the prompt in `normal` and highlighting in `last`;
|
||||
- the environment file with the shell's `PATH` entries;
|
||||
- the service manager's file.
|
||||
|
||||
## WP4 — The service manager's module, finished
|
||||
|
||||
*mesh-catalog. Half a day. From the review of 2026-10-04.*
|
||||
|
||||
**What changes.**
|
||||
|
||||
- System-scope `start`, `stop`, `restart`, `enable` and `disable` escalate with `sudo -n` when the
|
||||
runtime is not root, as the packet filter and intrusion modules do. They name a refusal by how it
|
||||
failed.
|
||||
- User scope reaches the account's manager by its runtime directory, which the runtime's environment
|
||||
lacks.
|
||||
- A failed `systemctl` is an error, not an empty list.
|
||||
- The package resource goes: the service manager is always present, and it collided with the network
|
||||
module's identical declaration on a machine running both.
|
||||
- `status` says whether the mesh declares the unit. The restore note is attached only to such a unit.
|
||||
- Tests cover a fake runner.
|
||||
|
||||
The user-scoped units of mesh-host #72 stay to-be 38's WP6.
|
||||
|
||||
**Proof.** The module's tests. Live, after WP5:
|
||||
|
||||
- `node-service-manager.units` answers in both scopes on a workstation and on a server;
|
||||
- `restart` of a harmless unit answers `ok`.
|
||||
|
||||
## WP5 — Assign and prove
|
||||
|
||||
*Operator-gated. Nothing here runs without the operator's go-ahead.*
|
||||
|
||||
**Order.**
|
||||
|
||||
1. Merge WP1 and roll the host.
|
||||
2. Merge WP2, and push the controller.
|
||||
3. Merge WP3 and WP4, and build the new modules by hand: a new catalogue module's first build is asked
|
||||
for, not automatic.
|
||||
4. On one server, assign `node-env`, `zsh`, `zsh-autosuggestions` and `zsh-syntax-highlighting`, and
|
||||
push. Then check:
|
||||
- `node-login-shell.execute@<server> command="echo $PATH"` shows the shell's entries;
|
||||
- `.zshrc` begins with the block;
|
||||
- the operator's lines follow untouched;
|
||||
- the environment file and the service manager's file exist.
|
||||
5. The operator deletes the duplicated lines, per the zsh module's documentation.
|
||||
6. The other server, then the two workstations, the workstations also with `powerlevel10k`.
|
||||
7. Assign `systemd` everywhere, and prove WP4.
|
||||
8. Unassign one plugin module on one machine. Its line leaves the block at the next push, and nothing
|
||||
else changes.
|
||||
|
||||
The follow-up records to-be 38 names are still owed:
|
||||
|
||||
- what a shell module assigned beside the holder does;
|
||||
- how a person's own environment variable is a setting rather than a line, once issue 168 closes.
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [mesh-catalog, mesh-controller, mesh-host]
|
||||
updated: 2026-10-04
|
||||
decisions:
|
||||
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
|
||||
- 02-DECISIONS/0040-what-a-module-is.md
|
||||
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
|
||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
||||
- 02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md
|
||||
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
|
||||
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
|
||||
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
|
||||
- 02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md
|
||||
- 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md
|
||||
- 02-DECISIONS/0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md
|
||||
- 02-DECISIONS/0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md
|
||||
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
|
||||
---
|
||||
|
||||
# 42. The machines' modules, in order
|
||||
|
||||
The order in which the modules of [research 026](../../01-RESEARCH/026-the-graphical-session-as-modules/00-overview.md)
|
||||
and [research 027](../../01-RESEARCH/027-the-system-layer-as-modules/00-overview.md) are built and rolled
|
||||
out, as the operator set it on 2026-10-04.
|
||||
|
||||
Three phases, in order, each built from the bottom up (the most core module first):
|
||||
|
||||
1. the modules every machine shares;
|
||||
2. those both workstations share;
|
||||
3. those of one machine model.
|
||||
|
||||
The shell came first ([to-be 41](41-the-shell-and-the-accounts-environment.md)). Each module's
|
||||
definition, improvements and tools follow the research. A position that needs a new mechanism (a new
|
||||
seat, a gated assignment, generalised contributions) gets its record when its first module needs it,
|
||||
not before. Modules that need none go ahead now.
|
||||
|
||||
## How every module moves
|
||||
|
||||
1. Written in the catalogue, with its tools and their tests, and checked by the controller's catalogue
|
||||
check.
|
||||
2. Merged, which builds it.
|
||||
3. Assigned to **the first workstation, the proving machine**, and pushed. Its tools and files are
|
||||
proven there.
|
||||
4. Then assigned to every other machine it applies to, and pushed.
|
||||
|
||||
The operator delegated the go-ahead for each step on 2026-10-04 ("non-important decisions, easily
|
||||
reversed"). Each step is reported.
|
||||
|
||||
**Adopting is also improving** (research 026, 027 overviews): every module lists what it fixes over
|
||||
today, and leaves no predecessor copy of what it now owns.
|
||||
|
||||
## Phase 1 — every machine
|
||||
|
||||
In order:
|
||||
|
||||
| | module | owns | improves |
|
||||
|---|---|---|---|
|
||||
| 1 | `sudo` | the operator account's escalation, as a drop-in it owns | declares what three modules' tools assume and nothing stated |
|
||||
| 2 | `localization` | locale, time zone, console keymap | one machine on another zone and keymap |
|
||||
| 3 | `time-sync` | timesyncd and its servers | two different daemons across four machines |
|
||||
| 4 | `pacman` | the package manager's configuration, mirrors and their refresh, cache cleaning | mirrors generated once and never again; caches never cleaned |
|
||||
| 5 | `logrotate` | the timer and base configuration | rotation running on one machine of four |
|
||||
| 6 | `avahi` | the daemon | on all four, owned by none |
|
||||
| 7 | `systemd` | the service manager's tools (to-be 41 WP4) | built, assigned nowhere |
|
||||
| 8 | `docker` | the runtime's packages, base configuration, group | four configurations, one owner on one machine |
|
||||
| 9 | `ssh-client` | everything under `~/.ssh` (research 027/03) | a predecessor's entries winning over the mesh's; stale keys |
|
||||
| 10 | scripts | the operator's own scripts, shared and per role (research 027/03) | under no version control, copied by hand |
|
||||
| 11 | `kernel` | kernel, microcode, boot entries | two machines without microcode |
|
||||
|
||||
`systemd`, `pacman` and `docker` hold the three seats that apply resources
|
||||
([ADR 0207](../../02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)):
|
||||
every module that declares a service, a package or a container depends on them being held on its node.
|
||||
They go on every machine before the rest, and once they have, an unmet dependency is refused rather
|
||||
than reported.
|
||||
|
||||
`kernel` is last because a mistake in it costs a boot. `docker` stays a module without the runtime seat
|
||||
until ADRs 0165 and 0166 are accepted.
|
||||
|
||||
**Added 2026-10-04.** Two more modules for every machine:
|
||||
- `power` holds `node-power` ([ADR 0211](../../02-DECISIONS/0211-a-machines-power-is-a-node-seat-its-moments-take-contributions-and-its-states-are-events.md)). Other modules contribute code for
|
||||
its moments (after boot, before sleep, after waking, before shutdown, on mains, on battery), and it
|
||||
publishes the machine's power states on the bus.
|
||||
- `dbus` holds the message bus. Modules shipping D-Bus policies or services contribute them to it.
|
||||
It shares curated events, never raw traffic. An upgrade never restarts the bus live: its package
|
||||
waits for a reboot. A live restart in the middle of a full upgrade took down a workstation's
|
||||
logins on the day this was written.
|
||||
|
||||
## Phase 2 — both workstations
|
||||
|
||||
In order:
|
||||
|
||||
1. `fonts`;
|
||||
2. `xorg` with autorandr;
|
||||
3. `lemurs`;
|
||||
4. `i3`;
|
||||
5. `xterm`;
|
||||
6. the theme module;
|
||||
7. `picom`, `rofi`, `dmenu`, `dunst`, the lock module, `xclip`, the clipboard manager, `feh` and
|
||||
`i3status-rust`;
|
||||
8. `gnome-keyring`;
|
||||
9. `docker-compose`, `snapd`, `flatpak`, `cups`, `bluetooth`.
|
||||
|
||||
The seats, gating, contributions and session start are [ADR 0208](../../02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md).
|
||||
|
||||
**Who writes what** is [ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md): a tool's configuration belongs to the
|
||||
holder of its seat. The launcher, the clipboard manager, the wallpaper and the bar contribute their
|
||||
window-manager lines to `node-display-session`, and the window manager's module places them. They do
|
||||
not write into its include directory. Each contribution is a dependency on the seat that receives it,
|
||||
so assigning one of them without a window manager is refused. The first versions, which still write
|
||||
the include files themselves, move to contributions once the controller derives the dependency.
|
||||
|
||||
**Added 2026-10-04.** `triggerhappy` holds `node-hotkeys` on both workstations ([ADR 0212](../../02-DECISIONS/0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md)). The
|
||||
laptop model's vendor keys become its contribution. The window-manager fragments of the launcher, the
|
||||
clipboard, the wallpaper, the bar and the laptop model become `config` contributions to
|
||||
`node-display-session`.
|
||||
|
||||
## Phase 3 — one machine model
|
||||
|
||||
The laptop's hardware module (vendor daemon, GPU mode, charge limit, logind, brightness and vendor keys)
|
||||
and `memory-pressure` (research 027/03).
|
||||
|
||||
## How it is checked
|
||||
|
||||
Each module's own tests and the catalogue check, at merge. On the proving machine, each tool answered
|
||||
through the mesh and each owned file checked in place, before any other machine is assigned. This
|
||||
document's tables are updated as each module lands.
|
||||
@@ -43,6 +43,8 @@ document is written and this one's status becomes `implemented`.
|
||||
| [`32-what-a-module-declares.md`](32-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md), superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) |
|
||||
| [`37-the-operators-machine.md`](37-the-operators-machine.md) | **In progress.** Every configurable thing on a node is a module, the home included; one default per module varied by settings or kept regions; roles a machine has once as seats with tool contracts; one tool runtime per node on the host side | [ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md), [0174](../../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md), [0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0176](../../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md), [0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md) |
|
||||
| [`38-building-the-operators-machine.md`](38-building-the-operators-machine.md) | **In progress.** The work of design 37 as packages: the runtime serves many modules, the controller composes one per node, the console becomes its serving mode, the packet filter moves first, then the shell and the service manager — tested on the live mesh by the operator's decision | [ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md), [0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [0149](../../02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md) |
|
||||
| [`41-the-shell-and-the-accounts-environment.md`](41-the-shell-and-the-accounts-environment.md) | **In progress.** The shell and the account's environment as modules: an environment module every module contributes variables and `PATH` entries to, shell code contributed to the login shell in named slots, the prompt and plugins as modules, the host giving a login back, and the service manager's module finished | [ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md), [ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md), [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md) |
|
||||
| [`42-the-machines-modules-in-order.md`](42-the-machines-modules-in-order.md) | **In progress.** The order the machines' modules of research 026 and 027 are built and rolled out: every machine's first (sudo, localization, time sync, pacman, logrotate, avahi, systemd, docker, `~/.ssh`, scripts, kernel), then both workstations', then one machine model's; each proven on one workstation before the rest | [ADR 0173](../../02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md), [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md), [ADR 0205](../../02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md) |
|
||||
|
||||
## Not yet written
|
||||
|
||||
|
||||
+33
-4
@@ -1,9 +1,9 @@
|
||||
---
|
||||
status: open
|
||||
status: resolved
|
||||
opened: 2026-09-23
|
||||
located-in: []
|
||||
fixed-by:
|
||||
amended-design:
|
||||
located-in: [mesh-controller internal/inventory, mesh-controller internal/artifacts, mesh-host internal/apply, mesh-catalog modules/distribution]
|
||||
fixed-by: 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
|
||||
amended-design: 03-DESIGN/01-to-be/18-building-a-module.md
|
||||
---
|
||||
|
||||
# 108 — The registry has no garbage collection, and two doors make it harder to add
|
||||
@@ -75,3 +75,32 @@ real thing services need, and the mesh cannot express one.
|
||||
images by digest and moves by version — is retention "the digests no recorded build names"?
|
||||
- Who owns the routine when the store and its public door are two modules — the store, since the
|
||||
volume is its?
|
||||
|
||||
## Answered, 2026-10-02 — [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)
|
||||
|
||||
The three open questions, answered:
|
||||
|
||||
- **A maintenance step, or a backend that does not need its writers stopped?** The step. A
|
||||
scheduled container may name `while-stopped` — resource ids of **its own module's** containers,
|
||||
which the host stops before the run and starts again after it whatever the step did. A storage
|
||||
backend the mesh does not run would be a bigger thing to own than the mechanism it avoids, and
|
||||
the mechanism is wanted anyway: a service that cannot have work done underneath it is a real
|
||||
shape and the mesh could not express it at all.
|
||||
- **Is retention "the digests no recorded build names"?** Nearly. An artifact stays because a
|
||||
definition the mesh holds names it (no age limit), or because it belongs to one of the five most
|
||||
recent successful builds of its module. Last-N-tags was the predecessor's rule for a registry
|
||||
that knew nothing else; this mesh knows what each digest is for.
|
||||
- **Who owns the routine now the second door is gone?** Both halves, each where it can be. The
|
||||
**mesh** decides what may go — only it holds the records — and asks the store to drop it. The
|
||||
**store** reclaims the bytes, because only it can stop its own server. Neither half can be done
|
||||
by the other.
|
||||
|
||||
And the sharpened point — enabling deletion on a door with no accounts — dissolved on inspection:
|
||||
**that door already accepts a push**, so a writer who can reach it can already replace any tag.
|
||||
Delete takes nothing a push did not have. What it does not do is undo ADR 0082's bargain, which
|
||||
putting an authenticated door in front of deletion would have.
|
||||
|
||||
The second registry process is not built, as the 2026-09-26 note says, so the shared blob cache
|
||||
and the deletion-cached-by-the-other-door problem never arise. Plain `garbage-collect` is enough:
|
||||
what the mesh keeps is still a manifest in the store, so `--delete-untagged` — the flag that would
|
||||
delete images machines are running — is not needed at all.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-09-26
|
||||
located-in: [mesh-controller internal/catalogue/declaration.go, mesh-sdk src/provisioner, mesh-catalog modules/minio]
|
||||
fixed-by:
|
||||
amended-design:
|
||||
located-in: [mesh-controller internal/catalogue, mesh-sdk src/provisioner, mesh-catalog modules/minio]
|
||||
fixed-by: 02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md
|
||||
amended-design: 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
|
||||
---
|
||||
|
||||
# 124 — A consumer cannot be told a value its provider derived for it, so it transcribes one
|
||||
@@ -63,3 +63,27 @@ compares it to what the provider will actually create. The one wrong instance wa
|
||||
- What would have caught the wrong instance? A test that resolves a consumer's grant and compares the
|
||||
bucket in its own configuration against the one the provider would create is a check that could
|
||||
exist today, for any interface, without the mechanism above.
|
||||
|
||||
## Answered, 2026-10-02 — [ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md)
|
||||
|
||||
The channel is the provider's own `serves` block, which may now name the consumer the mesh is
|
||||
serving: `${consumer:as}` and `${consumer:as:dns}`. The mesh fills it once, where it knows who the
|
||||
consumer is, and delivers the one filled value to both ends — the consumer's binding and its
|
||||
`${bound:…}` substitutions, and the provider's contributions entry, so a provisioner is told the
|
||||
name rather than deriving it. Each open question above, answered:
|
||||
|
||||
- **Should a provider return values from provisioning?** No. It would make a grant carry data the
|
||||
provider wrote, make a consumer's declaration wait on its provider's reconcile loop, and put the
|
||||
rule where nothing can refuse it. The reasoning is in the record.
|
||||
- **Or should `serves` say a value is derived?** Yes, and the mesh performs the derivation — but it
|
||||
learns no protocol doing it. The only fact is the identity the mesh itself minted, in one of two
|
||||
alphabets it already knows.
|
||||
- **Should a consumer that names the resource be refused?** Yes. A consumer's file that already
|
||||
contains the value the mesh is about to derive for it is refused at resolution, naming the
|
||||
placeholder to write instead. That is the check this report asked for, and it is exact rather than
|
||||
heuristic: a derived value carries the identity minted for this consumer on this machine, which
|
||||
nothing else would spell out.
|
||||
|
||||
minio's `bucketFor` is gone; its manifest serves `"bucket": "${consumer:as:dns}"`. The three
|
||||
consumers' hand-written bucket names are gone with it — each of them also named the machine the
|
||||
module happens to run on, which is the second thing wrong with a transcription.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-09-30
|
||||
located-in: [mesh-host internal/apply (no removal for an archive)]
|
||||
fixed-by:
|
||||
- mesh-host#90
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -54,3 +55,15 @@ argue for elsewhere: the mesh gives back what it found.
|
||||
A module with an archive is assigned, pushed, unassigned and pushed again; what the archive put on the
|
||||
machine is gone, anything that was in the directory beforehand is still there, and the apply that
|
||||
removed it applied everything else in the same declaration.
|
||||
|
||||
## Resolved
|
||||
|
||||
*2026-10-04.* Hit again live the same day: a race between two pushes delivered a declaration without
|
||||
a just-assigned module, and removing its tools bundle stopped a workstation's apply until the next push.
|
||||
mesh-host#90 records what an archive unpacked: its files, the directories it made, whether the host
|
||||
made the target and its parents. Undeclaring removes exactly those, never a file it did not place and
|
||||
never a directory that was there before, and a failed removal is reported, never fatal.
|
||||
|
||||
An archive recorded before the change learns its list from its own bytes on the next apply while it is
|
||||
still declared. One already orphaned is left in place, said and forgotten. Applying an archive no longer
|
||||
deletes files the mesh did not place in its target directory (ADR 0030).
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: open
|
||||
status: resolved
|
||||
opened: 2026-10-02
|
||||
located-in: []
|
||||
fixed-by:
|
||||
located-in: [mesh-controller]
|
||||
fixed-by: mesh-controller PR #270
|
||||
amended-design:
|
||||
---
|
||||
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# 195 — Diagnosis
|
||||
|
||||
## 2026-10-04
|
||||
|
||||
**The count had grown, and was still almost all noise.** Every push now opens with 137 users the mesh
|
||||
"has minted no credential for", across four machines. Checked against the catalogue: six modules declare
|
||||
an own secret named `broker`; every other module declares none.
|
||||
|
||||
**Where the line comes from.** The controller composes the bus's user list from its records: one user
|
||||
for the controller, one per machine, one per live enrolment token, one per person — and one per module
|
||||
assigned to a machine, whatever the module declares. Users with no minted credential are left out of the
|
||||
written file and named in the line. A module with no `broker` secret can never be minted one: issuing
|
||||
refuses it, because an account nothing reads is an orphan ([issue 078](../078-a-delivered-secret-is-accepted-under-any-name/00-report.md)).
|
||||
So for those modules the user was composed only to be left out and reported, on every status, plan and
|
||||
push.
|
||||
|
||||
**Why that is safe to stop.** Where the machine's tool runtime runs — every machine, now — the runtime
|
||||
is the module's way onto the bus ([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
|
||||
[ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)); its grants are the union of
|
||||
what the modules it carries declare, and that is unchanged. Where no runtime runs, a module without a
|
||||
`broker` secret cannot connect at all, and a user would not change that.
|
||||
|
||||
**What else read the module users.** Each module's durable consumer was derived from its own user. A
|
||||
module carried by the runtime and declaring no `broker` secret would have lost its consumer, and the
|
||||
runtime reads that consumer on the module's behalf (ADR 0198). The consumers are now derived from the
|
||||
module users and from what each runtime carries, one per module and machine.
|
||||
|
||||
**Ruled out as still open.** The report's first real gap — a declared `broker` secret filled with a
|
||||
generated value — was closed by [issue 203](../203-a-fresh-assignment-is-pushed-before-its-credential-exists/00-report.md): a push refuses to make one and names the verb that issues
|
||||
it. The second — a module that emits with no way onto the bus — has no case on a machine where the
|
||||
runtime runs, which is every machine now.
|
||||
|
||||
**Fix.** A module user is composed only for a module declaring an own secret named `broker`; the
|
||||
consumers are derived as above. The written accounts file is unchanged, since the users dropped never
|
||||
had a password. What the line names from now on is the real gap: a module that can read an account and
|
||||
has not been issued one. Checked by the broker package's tests: no user for a module without an
|
||||
account, and its consumer still made.
|
||||
|
||||
## Answers to the report's questions
|
||||
|
||||
- *Should a bus user be composed for a module that declares no `broker` secret?* No.
|
||||
- *Is a `broker` secret ever correctly made by the generic generator?* No; issue 203 already refuses it.
|
||||
- *Should a module that speaks on the bus be refused when it declares no `broker` secret?* Not while the
|
||||
runtime carries it; left for a machine without one, where no case exists today.
|
||||
|
||||
## Resolved — 2026-10-04
|
||||
|
||||
Live on the control node the same day. The first push after the controller restarted named no users
|
||||
without a credential, and the number of modules with a durable consumer was unchanged.
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-02
|
||||
located-in: [mesh-catalog modules/dnsmasq, mesh-controller cmd/mesh-controller]
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 202 — A module whose required setting nobody set is left out of the machine, and the resolver is the module it happened to
|
||||
|
||||
## What was observed
|
||||
|
||||
Running the controller's own test suite against the catalogue beside it, 2026-10-02.
|
||||
`TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves` fails with *"the resolver
|
||||
was not handed the machines"*. Composing the same machine by hand and listing what it receives
|
||||
shows why: **dnsmasq contributes nothing at all.** Four resources are composed for that node, all
|
||||
of them the overlay's. The resolver's package, its configuration, its service and the fact that
|
||||
carries every machine's name are simply not there.
|
||||
|
||||
The cause is one line added to `dnsmasq`'s configuration earlier the same day: the addresses it
|
||||
listens on beside the machine's own became an operator setting,
|
||||
`listen-address=${setting:listen-addresses}`, with no default. A `${setting:…}` nothing sets is
|
||||
refused, a module that cannot be composed is **left out** rather than failing the whole machine
|
||||
([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md)), and so a node
|
||||
assigned the resolver is handed a declaration with no resolver in it.
|
||||
|
||||
The failing test is the symptom that surfaced it. The test is not what is wrong.
|
||||
|
||||
**Proven rather than inferred.** Composing the same machine a second time with
|
||||
`listen-addresses` set to `127.0.0.1` and nothing else changed, every one of dnsmasq's eight
|
||||
resources appears — `needs-broker`, `mesh-state`, `package`, `config`, `runtime-dns`, `runtime`,
|
||||
`service` and `fact-node-zones`. The only difference between a machine with a resolver and a
|
||||
machine without one is whether somebody set a value that did not exist yesterday.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**Leaving a module out is right, and being quiet about it is not.** The rule exists so one
|
||||
module's broken setting cannot stop a machine converging — a good rule. But the outcome here is a
|
||||
machine that applies cleanly, reports current, and is missing its DNS resolver. Every name on that
|
||||
machine then resolves through whatever was there before, or not at all, and nothing in the mesh
|
||||
says the resolver was dropped. That is the shape
|
||||
[issue 152](../152-a-nodes-plan-failure-silently-drops-its-routed-names/00-report.md) records for
|
||||
routed names, here for a whole module.
|
||||
|
||||
**And a setting with no default is a definition that cannot be assigned.** Every other
|
||||
`${setting:…}` in the catalogue names something that is genuinely particular to one installation —
|
||||
a public domain, an issuer. "Which addresses besides my own do I answer on" has an obvious correct
|
||||
default for every machine that is not a LAN gateway: none beside loopback. A definition that
|
||||
refuses to compose until somebody sets a value most machines do not need is a definition that
|
||||
breaks the next node to be assigned it, and genesis with it.
|
||||
|
||||
## What this does not claim
|
||||
|
||||
Whether the live machines are affected was not checked — those four have had the setting set, or
|
||||
their resolvers would already be gone. The claim is about a machine assigned the resolver *from
|
||||
now on*, and about the silence.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should the declaration say which modules it left out, where a person or the console can see it?
|
||||
`left_out` already travels to the host ([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md));
|
||||
what is missing is anything that reads it back and says so.
|
||||
- ~~Should a `${setting:…}` be allowed a default?~~ **Decided in principle and not built.**
|
||||
[ADR 0164](../../02-DECISIONS/0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md)
|
||||
(proposed, 2026-10-01) says a setting with a default is a tunable and one without is the
|
||||
operator's, and narrows 0155's refusal to exactly the second. `listen-addresses` is a tunable by
|
||||
that rule, and dnsmasq declares no settings block at all. So this issue is, in part, 0164 waiting
|
||||
to be built — and in part the silence, which 0164 does not address.
|
||||
- Is leaving a module out ever right for a module a node is **assigned**, as opposed to one it
|
||||
merely pulls in? An assignment is somebody saying *this machine runs this*; silently not running
|
||||
it is the one answer nobody asked for.
|
||||
|
||||
## Still true on 2026-10-04, and the evidence had to be re-taken
|
||||
|
||||
Re-checked after the bundles refactor landed (fourteen records, ADRs 0188 and 0190–0200). **The
|
||||
fault stands and the old evidence no longer reaches it.**
|
||||
|
||||
`TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves` still fails on
|
||||
mesh-controller main, with the same message — and now for a *different first reason*. `assign` is
|
||||
refused before composition ever happens:
|
||||
|
||||
> dnsmasq on anchor has no bus credential: nothing was issued for anchor.dnsmasq … (novox/hq issue 203)
|
||||
|
||||
That is [issue 203](../203-a-fresh-assignment-is-pushed-before-its-credential-exists/00-report.md)'s
|
||||
new guard doing its job on a test harness that mints no credential. Two faults are stacked in one
|
||||
failing test, and the second was invisible behind the first.
|
||||
|
||||
Proven again, past both: mint `anchor.dnsmasq` so the assignment stands, then compose the machine
|
||||
twice. **Without `listen-addresses` the node composes four resources, all the overlay's. With it
|
||||
set to `127.0.0.1`, all eight of dnsmasq's appear** — `needs-broker`, `mesh-state`, `package`,
|
||||
`config`, `runtime-dns`, `runtime`, `service`, `fact-node-zones`. Nothing else differs.
|
||||
|
||||
The test is now wrong about two things and should be fixed with whichever of these is fixed first.
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-10-03
|
||||
located-in:
|
||||
- mesh-controller
|
||||
fixed-by:
|
||||
- mesh-controller#246
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -45,3 +46,7 @@ have to write — a bundle of language L depends on the module that publishes L'
|
||||
bundle lands in the tier after it. A test: a merge touching the toolchain module and a TypeScript
|
||||
bundle plans the bundle one tier later. Worked around on the day by building the bundle again once
|
||||
the toolchain was built.
|
||||
|
||||
## Resolved
|
||||
|
||||
Every mesh-tools plan since the fix ran in two rounds: the toolchain and runtime images first, what is built in or on them after. Before it, a merge moving both tiered them together. The planner's test proves the order: a merge moving a bundle and its toolchain plans two rounds.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-10-03
|
||||
located-in:
|
||||
- mesh-tools
|
||||
- mesh-controller
|
||||
fixed-by:
|
||||
- mesh-tools#44
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -42,3 +43,7 @@ install from a lockfile that a release updates, or build the install layer witho
|
||||
the build should record which SDK version the image carries, so a bundle's record says what it was
|
||||
compiled against. A check: after an SDK release and a toolchain rebuild, a bundle built on it reports
|
||||
the released version.
|
||||
|
||||
## Resolved
|
||||
|
||||
The toolchain now installs the exact SDK version the mesh last published, passed in as a build argument from the SDK module's published package, and the planner orders the toolchain after the SDK. Proven 2026-10-04: the toolchain built with the published SDK, and the six TypeScript bundles built in it serve their tools and seat verbs.
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
---
|
||||
status: open
|
||||
status: resolved
|
||||
opened: 2026-10-03
|
||||
located-in:
|
||||
- mesh-controller
|
||||
- mesh-catalog
|
||||
- mesh-host
|
||||
fixed-by:
|
||||
- mesh-host#86
|
||||
- mesh-controller#252
|
||||
- mesh-controller#253
|
||||
- mesh-host#88
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -50,3 +54,27 @@ of a plan today.
|
||||
|
||||
Located only by owner; the move is a change of the controller's module and its deployment, not of
|
||||
its code.
|
||||
|
||||
## Fix prepared (2026-10-04)
|
||||
|
||||
Three changes. mesh-host#86, merged: a process may name the container it `replaces`, and the host
|
||||
removes that container only once the process has stayed up across two checks. mesh-controller#252,
|
||||
awaiting the operator's merge: the controller's composition for a service process, and two
|
||||
controllers safe together for the handover — the second stands by on the controller's consumers
|
||||
until the first lets go, and all plan work holds one advisory lock. mesh-controller#253, held: the
|
||||
controller's manifest as a Go bundle and a process. It waits on
|
||||
[issue 223](../223-a-new-mesh-installs-its-controller-as-a-container/00-report.md), because with it a
|
||||
new mesh cannot be installed.
|
||||
|
||||
## Resolved
|
||||
|
||||
Proven 2026-10-04 on the control machine: after the manifest change was merged and pushed, the host
|
||||
created the controller's account, ran its preparation step, started the controller as a process,
|
||||
found it up across both checks and removed the container. The controller now runs as its own account
|
||||
from its bundle, no controller container remains, its seat answered throughout, and the merge's own
|
||||
plan finished all three tiers under the new process.
|
||||
|
||||
One fault on the way, fixed before it could leave two controllers or none: the host read the account
|
||||
not existing yet as a user database that did not answer — it matched the exit as text in a wording
|
||||
its own runner did not use — so the first apply stopped at the account and the container kept serving,
|
||||
which is the handover's safe failure (mesh-host#88).
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-10-03
|
||||
located-in:
|
||||
- mesh-controller
|
||||
fixed-by:
|
||||
- mesh-controller#246
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -34,3 +35,7 @@ Owner mesh-controller (the planner). **Fix direction:** on start, and whenever a
|
||||
build, the plan settles an `asked` build against the build records — a build recorded as built from
|
||||
the plan's commit is that tier's outcome — so a plan resumes after the controller replaced itself.
|
||||
A test: a plan whose build outcome was recorded while no controller followed it resumes on start.
|
||||
|
||||
## Resolved
|
||||
|
||||
A plan settles an asked build from the build records, whoever heard the outcome. Proven 2026-10-03 and 2026-10-04: both controller merge plans since the fix finished all three rounds, including the round that replaced the controller, without being stopped by hand.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-10-03
|
||||
located-in:
|
||||
- mesh-controller
|
||||
fixed-by:
|
||||
- mesh-controller#246
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -33,3 +34,9 @@ Owner mesh-controller. **Fix direction:** a build asked at a commit does not cha
|
||||
module follows; or, if pinning is meant, the pin is said — in `module list`, in `status`, and by a
|
||||
merge's plan naming the module it leaves out and why. A test: building a module at a commit and then
|
||||
merging a change to it plans it.
|
||||
|
||||
## Resolved
|
||||
|
||||
Proven 2026-10-04: the catalogue merges since the fix rebuilt the module that had been pinned at an
|
||||
old commit, at the merge's commit, by an ordinary plan — the same as every other module of the
|
||||
catalogue. Nothing was asked for it by hand.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-10-03
|
||||
located-in:
|
||||
- mesh-controller
|
||||
fixed-by:
|
||||
- mesh-controller#246
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -28,3 +29,7 @@ Owner mesh-controller (the catalogue's registration check). **Fix direction:** a
|
||||
loads, runs or unpacks — no `loads`, no `tools` list on its module, no resource naming it — is refused
|
||||
at registration, naming the field that would deliver it. A test: such a manifest is refused; adding
|
||||
`loads` admits it.
|
||||
|
||||
## Resolved
|
||||
|
||||
A bundle nothing would deliver is refused at registration, by name. Proven by the controller's tests; the catalogue's bundles all name what the runtime loads (mesh-catalog#243).
|
||||
|
||||
+7
-1
@@ -1,9 +1,11 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-10-03
|
||||
located-in:
|
||||
- mesh-tools
|
||||
fixed-by:
|
||||
- mesh-tools#42
|
||||
- mesh-tools#43
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -50,3 +52,7 @@ announcement subscriptions are allowed for every principal kind.
|
||||
|
||||
Recovered on the day without the forge's API: the toolchain images built by the controller straight
|
||||
from the fix branch, every module image rebuilt on them, and the machine pushed.
|
||||
|
||||
## Resolved
|
||||
|
||||
A refused announcement is logged and the runtime serves on, and every runtime subscribes only the discovery subjects its grants allow. Proven 2026-10-04: after rolling out to every machine, no container restarts anywhere, and the discovery console lists no runtime as not answering. A refused tool subscription was made non-fatal the same way afterwards, under issue 218 (mesh-tools#46).
|
||||
|
||||
+29
-2
@@ -1,9 +1,13 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-10-03
|
||||
located-in:
|
||||
- mesh-controller
|
||||
fixed-by: mesh-controller#248
|
||||
- mesh-tools
|
||||
fixed-by:
|
||||
- mesh-controller#248
|
||||
- mesh-tools#45
|
||||
- mesh-tools#46
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -63,3 +67,26 @@ and memberships come from the same list, so they cannot disagree.
|
||||
seats and loses the recorded mesh seat. Live, the discovery console's overview must show each
|
||||
mesh-scoped seat announced from exactly the holder the records name. Status moves to `resolved` once
|
||||
that holds after the fix is rolled out.
|
||||
|
||||
## A second cause, and what the rollout broke (2026-10-04)
|
||||
|
||||
With the grants corrected, calls to the store reached only the holder, yet the console still showed
|
||||
the seat announced from both machines. The module's runtime added every seat its start-up credential
|
||||
claims, even after the mesh issued a membership that left the seat out. Once a membership exists,
|
||||
it now alone decides which seat verbs a runtime serves (mesh-tools#45).
|
||||
|
||||
The rollout then exposed a third fault. The module on the machine that does not hold the seat was
|
||||
still running an image built before #45, so it subscribed to the seat's subject. The corrected grants
|
||||
refused that subscription, and the refusal ended the process. Its runtime crash-looped until the
|
||||
module was rebuilt on the new runtime image. The database itself kept running. A refused tool
|
||||
subscription is now logged and costs only that subject (mesh-tools#46), as a refused announcement
|
||||
already did ([issue 217](../217-a-refused-announcement-took-down-every-containers-runtime/00-report.md)).
|
||||
|
||||
The module was not rebuilt by the plan that rebuilt the runtime image. This is the ordering gap of
|
||||
[issue 211](../211-a-bundle-is-built-before-the-toolchain-it-is-compiled-in/00-report.md) seen
|
||||
from a container module.
|
||||
|
||||
**Proven 2026-10-04.** The discovery console's overview shows the store seat announced from the
|
||||
recorded holder only. Repeated calls to the store are answered by that machine, and the answers
|
||||
include the controller's own database. The non-holder still answers its own module tools. No
|
||||
container restarts on any machine.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in:
|
||||
- mesh-controller
|
||||
fixed-by:
|
||||
- mesh-controller#249
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 219 — An older build that finishes later replaces a newer one
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-03. Two merges to the runtime module came minutes apart. Each plan asked for every module
|
||||
built on the runtime image to be rebuilt. One module's two builds were of the same source and
|
||||
differed only in the runtime image they stood on:
|
||||
|
||||
| Build | Requested | Finished | Stood on |
|
||||
|---|---|---|---|
|
||||
| asked by the first plan | 21:33 | 22:04 | the runtime image before the fix |
|
||||
| asked by the second plan | 21:49 | 21:56 | the runtime image with the fix |
|
||||
|
||||
The older request finished last, and its image became the module's current artifact. The next push
|
||||
deployed it, and the module's runtime crash-looped on a fault the newer image had already fixed
|
||||
([issue 218](../218-a-mesh-seat-is-answered-by-a-module-that-does-not-hold-it/00-report.md)).
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
Which build is current should follow what it was built from, not which build machine was slowest.
|
||||
Whenever two plans overlap, which happens on any busy evening, a fix can be silently reverted by a
|
||||
build that started before it existed. Every passing check still passes.
|
||||
|
||||
## Where to look
|
||||
|
||||
How a finished build is recorded and how a module's current artifact is chosen. **How it is checked:**
|
||||
a test in which an older request completes after a newer one for the same artifact, and the newer
|
||||
stays current.
|
||||
|
||||
## Resolved
|
||||
|
||||
A build is ordered by when it was requested, read from the id the controller gives it, and a
|
||||
module's registered manifest is replaced only by a build requested at or after the one it came from.
|
||||
An older request finishing later is recorded and changes nothing; a plan settles only from builds it
|
||||
asked for itself. **How it is checked:** store-backed tests replay the incident — the newer request
|
||||
stays what the module is — and fail without the fix. Live since 2026-10-04: the rebuilds of every
|
||||
runtime-image module and the three waves of module code moves since then each registered the build
|
||||
they asked for.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in:
|
||||
- mesh-host
|
||||
fixed-by:
|
||||
- mesh-host#84
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 220 — A delivered bundle keeps the files of the one before
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04. A tools bundle was rebuilt as one self-contained file per entrypoint
|
||||
([ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)),
|
||||
so it no longer carries a package directory. On the machine it was delivered to, its directory still
|
||||
held the package directory and a compiled file from the earlier delivery, dated hours before the
|
||||
new files. The new files were written over the old directory, and nothing removed what the new
|
||||
bundle no longer contains.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
A bundle on disk should be exactly the artifact that was built. Leftover files can be imported by
|
||||
code that should no longer find them. A fix that removes a file then works on a fresh machine and
|
||||
fails on every machine that ran an earlier version. It also makes "what runs here" impossible to
|
||||
read from the artifact.
|
||||
|
||||
## Where to look
|
||||
|
||||
How the host unpacks a bundle into its directory. **How it is checked:** deliver a bundle, then a
|
||||
version without one of its files, and the file is gone.
|
||||
|
||||
## Resolved
|
||||
|
||||
An archive is unpacked into a fresh directory beside the old one and swapped in by rename; a refused
|
||||
or failed unpack leaves the old tree whole. **How it is checked:** a second delivery without a file
|
||||
removes it, and nothing is left beside the directory; both tests fail without the fix. Proven
|
||||
2026-10-04: a bundle rebuilt and delivered after the fix holds exactly the new build and nothing
|
||||
beside it. A bundle that has not changed since keeps its old leftovers until its next version, by
|
||||
design: an unchanged archive is not unpacked again.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in:
|
||||
- mesh-controller
|
||||
fixed-by:
|
||||
- policy: `upgrade build-agent roll-out` (2026-10-04)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 221 — A build machine learns a new builder only from a push
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04. A controller merge changed how TypeScript bundles are built: one file per entrypoint
|
||||
instead of a package directory. Its plan finished, and six bundles were rebuilt right after. They
|
||||
came out in the old shape, because the build machines still ran the previous builder. They got the
|
||||
new one only from the next push. Rebuilt after that push, the same six came out right.
|
||||
|
||||
The same order showed in the bus grants the same night. A push sent while the controller was still
|
||||
the previous build composed grants with the previous code. A second push was needed after the new
|
||||
controller had started.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
A merge to the controller is not in effect when its plan says done. Anything built or pushed in the
|
||||
gap uses the old code and looks current. Today only the operator knows to push first and build
|
||||
second, and even the operator forgot.
|
||||
|
||||
## Where to look
|
||||
|
||||
Whether a controller plan should end by delivering itself to the build machines and the control
|
||||
machine, or whether a build should refuse a builder older than the controller that asked for it.
|
||||
**How it is checked:** after a controller merge, a build asked right after its plan finishes runs
|
||||
the new builder.
|
||||
|
||||
## Located
|
||||
|
||||
The mechanism existed: a module whose upgrade policy is `roll-out` is sent to its machines when its
|
||||
tier is built, and the plan's next tier waits until it is applied. The build agent's policy was
|
||||
`record` — built, never sent — as was that of 76 other modules, which is why every rollout on
|
||||
2026-10-03 needed a push by hand. The build agent was set to `roll-out`, one machine at a time,
|
||||
stopping at the first failure. **How it is checked:** the next controller merge's plan sends the
|
||||
build agent to the build machines before its last tier, and a build asked right after the plan
|
||||
finishes runs the new builder; then this moves to resolved. Whether the other modules roll out is
|
||||
the operator's policy, not this issue's.
|
||||
|
||||
## Resolved
|
||||
|
||||
Proven 2026-10-04: the next controller merge's plan logged that its first tier was built and the
|
||||
build agent sent to all four build machines, and only then asked its next tier. The builder change
|
||||
in that merge reached the build machines without a hand push.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in:
|
||||
- mesh-tools
|
||||
fixed-by:
|
||||
- mesh-tools#47
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 222 — An assignment is refused on the bus until the bus's machine is pushed
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04. A module was assigned to the laptop, and the laptop alone was pushed. The module's two
|
||||
bundles arrived and the node's runtime launched both, and then the bus refused every one of the
|
||||
module's subjects:
|
||||
|
||||
```
|
||||
nats: permissions violation: Permissions Violation for Subscription to "mesh.mod.<module>.tool.<tool>.<node>"
|
||||
```
|
||||
|
||||
The tools were unreachable until a later push that included the machine running the bus. Then the
|
||||
runtime served them without a restart, because a new membership arrived and it re-subscribed.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
What an account may answer lives in the bus's user list, and the controller writes that list only
|
||||
into the declaration of the machine that runs the bus. Assigning anything to any machine changes
|
||||
that list, so `push <machine>` after `assign <machine> <module>`, which is what the controller itself
|
||||
tells the operator to run, leaves the module running and unreachable, with nothing reporting a fault.
|
||||
|
||||
## Where to look
|
||||
|
||||
Whether a push to one machine should also send the bus's machine when the user list it would
|
||||
compose differs from the one that machine holds. **How it is checked:** assign a module with tools
|
||||
to a machine that does not run the bus, push only that machine, and its tools answer.
|
||||
|
||||
## Diagnosed and resolved
|
||||
|
||||
The push did send the bus's machine: the controller's log shows both machines applying in the same
|
||||
second. The fault was the order within that second. The node's runtime subscribed before the bus
|
||||
had reloaded its user list, the bus refused, and the bus client marks a refused subscription dead.
|
||||
Nothing asked again until a later membership happened to re-serve the module.
|
||||
|
||||
A subject the runtime answers on is now asked for again when the bus refuses it, after waits from
|
||||
two seconds to two minutes, and given up and said after about five minutes. **How it is checked:**
|
||||
a test refuses a subject and finds it asked for again and answering, given up past its attempts,
|
||||
and not asked again once stopped; and by hand against a bus whose permissions were reloaded while
|
||||
connected, the runtime answered two seconds after the grant arrived, where the runtime before the
|
||||
fix never answered.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in:
|
||||
- mesh-host
|
||||
fixed-by:
|
||||
- mesh-host#87
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 223 — A new mesh installs its controller as a container
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04. Preparing [issue 213](../213-the-controller-is-a-go-program-run-in-a-container/00-report.md),
|
||||
the controller's own manifest was changed to a Go bundle run as a process. The installer that raises a
|
||||
new mesh assumes the controller is an image and a container at every step from its third on:
|
||||
|
||||
- it requires the controller's build to produce exactly one image;
|
||||
- it starts a temporary controller from that image, and publishes the image to the registry;
|
||||
- at the pivot, it finds the controller's container in the declaration, reads its environment and
|
||||
volumes, waits for it, and from then on talks to the controller only through the container.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
With the controller's manifest changed, a new mesh cannot be installed: the pivot fails. The deeper
|
||||
constraint is ordering. A process's bundle is fetched from the artifact store, and the installer
|
||||
raises the artifact store only after the pivot, so the controller's first declaration names a bundle
|
||||
nothing can serve yet.
|
||||
|
||||
## What a fix has to settle
|
||||
|
||||
One of two shapes, and it is a decision, not a repair:
|
||||
|
||||
1. raise the artifact store before the pivot, publish the controller's bundle to it, and talk to the
|
||||
controller from the host's side rather than through a container; or
|
||||
2. pivot to the image form as today, and let the first push hand over to the process, which
|
||||
requires the controller's manifest to carry both forms.
|
||||
|
||||
Until it is settled, the change of the controller's manifest (mesh-controller#253) is held. The
|
||||
handover itself is built and merged (mesh-host#86); the controller's half (mesh-controller#252) waits
|
||||
on the operator. **How it is checked:** the installer's test raises a mesh whose controller manifest
|
||||
is the process form, and the controller answers its seat's verbs at the end.
|
||||
|
||||
## Decided (2026-10-04)
|
||||
|
||||
Option 2, [ADR 0200](../../02-DECISIONS/0200-genesis-pivots-to-the-controller-as-a-container-and-the-first-push-hands-it-to-a-process.md):
|
||||
genesis pivots to the controller as a container recorded under the name the process `replaces`, and
|
||||
the first push hands it over.
|
||||
|
||||
## Resolved
|
||||
|
||||
Built as [ADR 0200](../../02-DECISIONS/0200-genesis-pivots-to-the-controller-as-a-container-and-the-first-push-hands-it-to-a-process.md)
|
||||
decided: genesis reads only the controller's process from the manifest, builds the controller's image
|
||||
from its repository, and pivots to a container of its own shape recorded under the id the process
|
||||
`replaces`; an older controller in the image form still builds as before. **How it is checked:** the
|
||||
installer's tests build the genesis form from the controller's real manifest, and apply the
|
||||
controller's first process declaration over the recorded container with the host's own apply — the
|
||||
process starts, the container is removed, one controller remains. A real install from nothing has not
|
||||
been run since; the handover it ends in was proven live on the running mesh (issue 213).
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in: [mesh-host internal/apply/apply.go, mesh-host internal/apply/schedule.go]
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 224 — An apply arriving during a maintenance window reopens it, by recreating the container the window is holding still
|
||||
|
||||
## What was observed
|
||||
|
||||
Reviewing [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)'s
|
||||
`while-stopped` before merging it, 2026-10-04. Found by reading, not by running.
|
||||
|
||||
A scheduled step may hold its module's containers still while it runs. The host stops them, runs
|
||||
the step, starts them again. Nothing tells the **apply** that a window is open, and the apply's
|
||||
rule for a container it finds stopped is to replace it:
|
||||
|
||||
```
|
||||
case existed && (before.Spec == want || legacy) && before.Running && len(reasons) == 0:
|
||||
out.Action = "unchanged"
|
||||
case existed:
|
||||
rm -f
|
||||
```
|
||||
|
||||
`before.Running` is false for a container a window is holding, so the second branch takes it:
|
||||
the container is removed and recreated, **running**, in the middle of the step that required it
|
||||
to be still.
|
||||
|
||||
## Why it matters
|
||||
|
||||
For the store, which is what the field was built for, the chain is: a push lands at 03:30 → the
|
||||
apply recreates the registry → the registry accepts an upload from a build running at the same
|
||||
time → `garbage-collect`, already past its mark phase, sweeps the blob that upload just wrote.
|
||||
The image is then in the store with a layer missing, and the build that made it reported success.
|
||||
|
||||
Two things have to coincide, so it is not likely. It is also not rare enough to leave unsaid: the
|
||||
mesh pushes on every merge, at any hour, and a collection over a store this size is minutes rather
|
||||
than seconds.
|
||||
|
||||
**The general shape is the one that matters.** `while-stopped` is the first thing in the mesh that
|
||||
makes a container's stopped state *intentional*. Everything else in the host reads "stopped" as
|
||||
"broken, fix it", which is right everywhere else and wrong here. Any future use of the field
|
||||
inherits this.
|
||||
|
||||
## What this is not
|
||||
|
||||
Not a regression. The store has never collected anything, so nothing is worse than it was; this
|
||||
is a hole in something new rather than something that broke.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Should a window take the apply lock?** The daemon already serialises applies with `applying`
|
||||
and, across processes, with `store.Lock`. A window that held it would make the race impossible.
|
||||
The cost is that a push arriving mid-window waits for minutes, and a push that waits is what
|
||||
[issue 185](../185-a-refused-membership-publish-stops-the-controller/00-report.md)'s
|
||||
family of outages looked like from outside.
|
||||
- **Or should the apply learn that a container is held?** Narrower: the scheduler says which
|
||||
containers a window currently holds, and `applyContainer` reports those unchanged instead of
|
||||
recreating them. Nothing blocks, and the apply tells the truth for the minutes it matters —
|
||||
at the cost of a second source for "is this container meant to be running".
|
||||
- Either way: should the *report* say a window is open, so a machine that looks half-stopped at
|
||||
03:31 reads as working rather than broken?
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in: [mesh-host internal/apply, mesh-controller internal/catalogue]
|
||||
fixed-by: mesh-controller#263
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 225 — A provisioner cannot read the grant secrets since its code left the container, and every consumer of it is unserved
|
||||
|
||||
## What was observed
|
||||
|
||||
On the control machine, 2026-10-04, found while looking at why one app was restarting:
|
||||
|
||||
```
|
||||
[mongodb] [provisioner:mongodb-database] mesh_novox_photos: secret not readable yet
|
||||
(/var/lib/mongodb/grants/novox.photos.secret):
|
||||
Error: EACCES: permission denied, open '/var/lib/mongodb/grants/novox.photos.secret'
|
||||
```
|
||||
|
||||
**4330 times, every five seconds, since 01:30:20.** The consequence is not a log line: the
|
||||
provisioner never reads the password, so it never creates the user, so the consumer never
|
||||
connects —
|
||||
|
||||
```
|
||||
UserNotFound: Could not find user "mesh_novox_photos" for db "admin"
|
||||
```
|
||||
|
||||
— and the app crash-loops. Two consumers on this machine are in that state.
|
||||
|
||||
## Why
|
||||
|
||||
The grant secrets are what the mesh seals for each consumer and the host unseals beside the
|
||||
provider's contributions file. They are written `-rw------- root root`, which was right while a
|
||||
module's own code ran in a container as root.
|
||||
|
||||
[ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)
|
||||
moved a module's long-running code out of its container and under the node's runtime, which runs
|
||||
as the operator's account. The provisioner is now that account; the secret is still root's. The
|
||||
timestamps say it exactly: the files are dated 2026-09-26, the first refusal is 01:30:20 on the
|
||||
day the runtime rolled.
|
||||
|
||||
**Nothing reports it.** The machine applies cleanly and reads as current; the provisioner says
|
||||
`secret not readable yet`, whose wording is for a real and ordinary race on the first pass — the
|
||||
host has not written the file yet — and which is indistinguishable, in the log, from a permanent
|
||||
refusal. Four thousand occurrences of a message that means "wait a moment" is the shape to
|
||||
recognise.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
This is every provider that provisions. The grant secret is the one file the sdk's harness reads
|
||||
for every consumer, so a provider that cannot read it serves nobody — and says so only in a line
|
||||
that reads like patience.
|
||||
|
||||
It is also the general question the runtime move leaves: **what the mesh seals for a module is
|
||||
owned for the shape that module's code used to have.** Each module whose code moved is a module
|
||||
whose files may now be unreadable to it, and ownership is the mesh's to state, not the module's
|
||||
to work around.
|
||||
|
||||
## What this is not
|
||||
|
||||
Not caused by [ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md)
|
||||
or [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md), which landed two
|
||||
to three hours after the first refusal. Those rebuilt the two affected consumers, which recreated
|
||||
their containers and made a silent fault visible as a restarting one. The dates are above.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Who owns a grant secret now — the module's account, as `secrets-owner` already says for a
|
||||
module's own secrets? Then the host writes it so, and this is a one-line statement in the
|
||||
declaration rather than a convention.
|
||||
- Should `secret not readable yet` stop saying "yet" after the first few passes? A message that
|
||||
is right once and wrong four thousand times is a message that hides its own meaning.
|
||||
- Which other modules' files did the runtime move leave behind? The sweep is the same question
|
||||
for every path the mesh writes for a module: directories, bundles, received files.
|
||||
|
||||
## Resolved, 2026-10-04
|
||||
|
||||
A grant secret is composed with the account that will read it: the node's account where the
|
||||
module's code is a bundle the runtime runs, its declared `secrets-owner` where it is still a
|
||||
container, root where it says neither. The same rule the module's own secrets already followed,
|
||||
reaching the other kind of secret the mesh writes for a module (mesh-controller#263).
|
||||
|
||||
`givenTo` could not have reached these: it claims the files a bundle's *words* name, and the
|
||||
harness composes a grant secret's path from the contributions file, which no word names.
|
||||
|
||||
And the harness stops calling a permanent refusal a race (mesh-sdk#22, 0.1.9). After about a
|
||||
minute it says so plainly, rarely rather than every five seconds, and names what to look at —
|
||||
who owns the file and who the process runs as. That is the half that cost three hours.
|
||||
|
||||
**How it was checked:** on the control machine, after the push — the grant secrets belong to the
|
||||
operator's account, **zero `EACCES` since 12:30:10** where there had been 4330, both users were
|
||||
created, and `mongodb-server` logs `Authentication succeeded` for each.
|
||||
|
||||
It also uncovered [issue 232](../232-a-consumer-authenticates-against-a-database-its-user-does-not-live-in/00-report.md),
|
||||
which this fault had been hiding: with no user anywhere, "not found in admin" was a complete
|
||||
account of *this* issue and said nothing about the consumer asking the wrong database.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in: [mesh-controller cmd/mesh-controller/collect.go, mesh-controller internal/inventory/collection.go]
|
||||
fixed-by: mesh-controller#263
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 226 — The store's sweep stops at the first reference recorded with an address, so it collects nothing at all
|
||||
|
||||
## What was observed
|
||||
|
||||
The first live run of [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)'s
|
||||
sweep, 2026-10-04, printed on every build:
|
||||
|
||||
```
|
||||
the artifact store kept 127.0.0.1:5100/mesh-tools/build@sha256:0de48cd3…, so nothing more was
|
||||
asked of it: 127.0.0.1:5100/mesh-tools/build@sha256:0de48cd3… is not a reference into the
|
||||
mesh's artifact store
|
||||
1681 more to collect; the next build asks again
|
||||
```
|
||||
|
||||
Nothing is collected, and nothing ever will be. The store holds 1681 artifacts the mesh no longer
|
||||
keeps and the feature that exists to remove them is inert.
|
||||
|
||||
## Why
|
||||
|
||||
Two correct decisions meeting badly.
|
||||
|
||||
**A reference recorded before references were kept without an address** is
|
||||
`127.0.0.1:5100/<path>@sha256:…` rather than `artifact-store://<path>@sha256:…`
|
||||
([04-ISSUES/102](../102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md)).
|
||||
`LetGo` rightly refuses to compose a delete for a reference whose shape it does not recognise —
|
||||
that refusal is what keeps the sweep from reaching something that is not the mesh's.
|
||||
|
||||
**The sweep stops at the first refusal**, because "a store that refuses one refuses all of them"
|
||||
— deletion disabled, the store down, the network gone — and pushing through would mean a hundred
|
||||
identical failures in front of whoever was building something. That reasoning is right for the
|
||||
store refusing. It is wrong for *this* record being unreadable.
|
||||
|
||||
So one old record, early in the oldest-first order, halts the whole sweep for ever.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**A guard that cannot tell "I will not ask about this" from "it would not answer" stops the wrong
|
||||
amount of work.** The two deserve opposite responses: skip one, abandon the other. Collapsing
|
||||
them into "an error" is how a bounded, cautious loop becomes a loop that does nothing — and it
|
||||
reports the right number while doing it, which is what made it look healthy.
|
||||
|
||||
## What a fix has to settle
|
||||
|
||||
- A reference the sweep cannot address is **skipped, and the sweep goes on** — it is a fact about
|
||||
that record, not about the store.
|
||||
- `Recorded()` already normalises the old form to the kept one, and is what the rest of the mesh
|
||||
uses for exactly these references. The sweep should normalise before asking rather than refuse.
|
||||
- Only a refusal *by the store* ends a sweep.
|
||||
- **How it is checked:** a sweep over records holding one address-recorded reference and one kept
|
||||
one collects the second; a sweep against a store that refuses stops at the first.
|
||||
|
||||
## Resolved, 2026-10-04
|
||||
|
||||
Two changes, deliberately separate.
|
||||
|
||||
**Normalising moved to where the provenance is known.** Every reference the sweep handles came
|
||||
from a build record, so every one is the mesh's own and `Recorded` may read an address-era
|
||||
reference as the kept one. Not in `LetGo` — it cannot tell `docker.io` from the mesh's store, and
|
||||
an attempt to put it there was caught at once by the test that says a foreign reference is never
|
||||
asked about. The guard stays strict; the records speak the one vocabulary.
|
||||
|
||||
**A reference the sweep will not address is `ErrNotOurs`:** skipped, not marked collected, never
|
||||
a reason to stop. Only the store refusing ends a sweep.
|
||||
|
||||
**How it was checked:** the first build after the roll-out printed *"the artifact store let go of
|
||||
200 artifact(s) the mesh no longer keeps — 1126 more to collect; the next build asks again"*.
|
||||
Two hundred is the per-sweep bound working as intended; the backlog is falling with every build
|
||||
instead of standing at 1681 for ever.
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in: [mesh-catalog modules/photos]
|
||||
fixed-by: mesh-catalog#263, mesh-controller#263
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 227 — The photo app's admin client asks for port 80, which the reverse proxy holds, so it cannot start
|
||||
|
||||
## What was observed
|
||||
|
||||
Applying the control machine, 2026-10-04:
|
||||
|
||||
```
|
||||
applying "photos.admin-client": starting container photos-admin-client:
|
||||
failed to bind host port 0.0.0.0:80/tcp: address already in use
|
||||
```
|
||||
|
||||
Port 80 on that machine belongs to the reverse proxy (`mesh-route-proxy`, confirmed with `ss`),
|
||||
which is the whole arrangement: the proxy holds the public ports and every module is reached
|
||||
through it. A module that publishes 80 itself can never start beside it.
|
||||
|
||||
Everything else on the machine applied; this one resource fails every pass.
|
||||
|
||||
## How it surfaced
|
||||
|
||||
`photos` had been pinned at a commit from 2026-09-28 and was rebuilt to `main` on 2026-10-04 —
|
||||
forced by [ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md)'s
|
||||
refusal of its transcribed bucket name. The admin client is one of the changes that came with the
|
||||
rest of `main`. The rebuild did not create the conflict; it delivered it.
|
||||
|
||||
**A module pinned months behind carries whatever its branch gained, all at once, the first time
|
||||
something makes it move.** That is the cost of a pin, and it is paid in full rather than
|
||||
gradually.
|
||||
|
||||
## What a fix has to settle
|
||||
|
||||
- Which port the admin client should ask for, or whether it should be reached through the proxy
|
||||
like everything else and publish nothing.
|
||||
- Whether a module declaring a port the machine's proxy already holds should be refused when it
|
||||
is composed, rather than failing on the machine every pass. The mesh assigns ports
|
||||
([ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md)); a fixed 80 beside a proxy is
|
||||
a statement it could check.
|
||||
|
||||
## Resolved, 2026-10-04
|
||||
|
||||
The three photo modules publish the endpoint they declare — `4001:80`, `4012:80`, `4013:80` —
|
||||
so the software's own port reaches the machine at the port the mesh assigned, and nothing asks
|
||||
for 80.
|
||||
|
||||
**The rule, rather than three repairs.** A container may publish only a port its module declares:
|
||||
the short form `"80"` means *publish what the software calls 80*, and the mesh fills in the
|
||||
machine's half from the port it assigned — which it can only do for a port the module declared.
|
||||
Four modules publish 80 quite safely because they declare 80. The difference is the declaration,
|
||||
not the number. A catalogue-wide test in mesh-controller#263 says so, and names all three
|
||||
offenders against the catalogue as it was.
|
||||
|
||||
**How it was checked:** `photos-admin-client` has been up since the push, where before it failed
|
||||
on every pass.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
status: located
|
||||
opened: 2026-10-04
|
||||
located-in:
|
||||
- mesh-host
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 228 — A login the mesh set is never given back, and undeclaring one stops the node applying
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04. Before the shell module of to-be 38 WP5 was assigned anywhere, a review traced what the
|
||||
host does with the `user` shape the module declares (the operator account, with the login shell zsh)
|
||||
on three events: first assign, a later push, and unassign.
|
||||
|
||||
1. **Undeclaring a `user` stops the node applying anything, for good.**
|
||||
- The host's removal has no case for a `user`, so the orphaned record fails with "no way to remove".
|
||||
- Orphans are removed before the declaration's first resource, and that failure aborts the apply.
|
||||
- The record stays in the host's store, so every later apply fails the same way.
|
||||
|
||||
This was reproduced in a throwaway test against the host's code: the apply produced no outcomes,
|
||||
and an unrelated file in the same declaration was not written. Renaming the resource's id has the
|
||||
same effect. A showcase module carries a `user` today and is exposed to it too.
|
||||
2. **The shell the account had is never recorded.** [ADR 0176](../../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
|
||||
§2 says the host "gives back [the login shell] when the holding moves". The host keeps nothing to
|
||||
give back.
|
||||
3. **A login shell is set whether or not it exists.** A failed package install does not stop the
|
||||
resources after it. `usermod --shell` on the distribution only warns about a missing or
|
||||
non-executable shell, and succeeds. The host's read-back compares the user database's string,
|
||||
which matches. So an account can be pointed at a shell that is not there, and console, ssh and
|
||||
display-manager logins then fail. No machine hit this, because zsh was already installed on all
|
||||
four.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
Unassigning any module with a login in it, the case the mesh promises is ordinary, wedges the
|
||||
machine's applies until a person edits the host's store. It is the same class of failure as an
|
||||
earlier archive that could not be removed: a shape the host can create and cannot take away.
|
||||
|
||||
## Located
|
||||
|
||||
mesh-host, `internal/apply`: `remove()` has no `user` case, and `applyUser` neither records the shell
|
||||
it replaced nor checks the shell it sets. The fix is set out in
|
||||
[to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md) WP1:
|
||||
|
||||
- a removal that never deletes an account;
|
||||
- the login shell given back, if it is still the one the mesh set and the recorded one still exists;
|
||||
- a shell refused before it is set unless it is executable and listed among the machine's shells
|
||||
(a shell that refuses logins need only be executable, since the distribution does not list it and
|
||||
the controller's own account uses one).
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in: []
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 229 — A rollout cannot be followed through the mesh's tools, so an agent goes round them
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04, rolling out to-be 41. An agent drove the rollout through the mesh's MCP tools: the
|
||||
controller seat's `status`, `plans`, `command`, and the forge's merge. Four times it left those tools
|
||||
and posted JSON-RPC by hand to the node console's HTTP endpoint with `curl`:
|
||||
|
||||
1. **To wait for a plan.** `plans` answers once, with prose. Nothing waits for a plan to reach a tier,
|
||||
finish or fail. An agent's tools cannot be called from a shell loop, so the only way to be told
|
||||
when a plan moved was a background `curl` loop polling the console every twenty seconds and
|
||||
matching the plan's line with `grep`.
|
||||
2. **To read `status`.** `status` answers a paragraph of prose (the bus's user list), then a JSON
|
||||
document, both inside one string. Picking out `behind`, `waiting` and `reported` took a script
|
||||
that cut the string at the first brace and parsed the rest.
|
||||
3. **To read one module out of `module list`,** whose output was too long to read whole for one line.
|
||||
4. **To call a tool that arrived after the agent's session began.** The modules rolled out in that same
|
||||
session added `node-login-shell.execute` and `zsh.zsh_config` to one machine. The agent's MCP
|
||||
connection had been opened before the console moved to discovery ([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)).
|
||||
It still held the flat catalogue the console announced then, which lacks both the new verbs and the five discovery tools (`mesh_call` among
|
||||
them) the console announces now. Clearing a session does not reconnect its MCP servers, and the
|
||||
console never sends a list-changed notice, so nothing told the client its list was stale. The agent
|
||||
posted `mesh_machine` and `mesh_call` by hand. Reconnecting the server would have given it the
|
||||
discovery tools, which reach any tool by address the moment it exists.
|
||||
|
||||
The calls were authorised, because the console is the operator's own surface. But each is a raw call
|
||||
the mesh's tools were meant to make unnecessary ([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)
|
||||
puts the controller's verbs behind the seat). Each is also a script that breaks silently when a
|
||||
sentence in the prose changes.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
Every rollout an agent drives has the same shape: merge, wait for a plan, push, wait for reports,
|
||||
check `status`. When the tools answer only once and only in prose, every agent writes its own poller
|
||||
and its own parser. Those are invisible to review, different each time, and wrong the first time the
|
||||
wording moves. An agent that cannot wait also tends to act early, which is the opposite of what a
|
||||
rollout needs.
|
||||
|
||||
## What a fix has to settle
|
||||
|
||||
- A way to **wait** on the mesh's own progress. For example, `plans` and `status` could take a plan
|
||||
or node and a bound, and answer when it moves or the bound passes. Or a verb could follow one plan
|
||||
to its end.
|
||||
- **Structured answers** from the controller's verbs, with the prose as a field beside the data, not
|
||||
around it.
|
||||
- Whether `command`'s generic answer should take a filter, or whether the verbs it is used for most
|
||||
(`module list`, `node show`) deserve verbs of their own.
|
||||
- **A client is told when the console's own surface changes.** The console announces `listChanged`
|
||||
and sends the notice when what it lists changes, for example after an upgrade that changes its
|
||||
tools. A long-running session then never keeps a list the console no longer serves. Discovery
|
||||
already makes every module's tools reachable without the list changing.
|
||||
|
||||
How each is checked belongs to the record that settles it.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in:
|
||||
- mesh-host
|
||||
- mesh-controller
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 230 — A host that hands over to a newer one loses its report, and a plan waits for it for ever without saying so
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04, rolling out to-be 41. A new host build and a new controller were merged together. The
|
||||
controller's plan built build-agent and sent every machine a fresh declaration, which also delivered
|
||||
the new host. Three of the four machines logged, within the same second:
|
||||
|
||||
```
|
||||
host 4bd7df099757 is delivered; standing aside so the launcher runs it
|
||||
applied 546 resource(s)
|
||||
applied, and could not tell the mesh: reporting: context canceled
|
||||
nox-mesh-host-launch: running /usr/lib/nox-mesh-host/versions/4bd7df099757/nox-mesh-host
|
||||
```
|
||||
|
||||
The new host came up and waited for its next declaration. The mesh never heard that the old one had
|
||||
applied.
|
||||
|
||||
The plan then sat at "tier 1 built; waiting for build-agent on [three machines] to be applied", and
|
||||
everything the mesh said about it read as healthy:
|
||||
|
||||
- `status` showed it as `rolling` with `"late": false`;
|
||||
- `plans` printed "for 0s" on every look, so the wait never appeared to grow;
|
||||
- the three machines' reports showed `current: false`, which reads like a machine that is merely slow.
|
||||
|
||||
Nothing logged, alerted or counted the wait. It was found because a person asked twice for the plan's
|
||||
state, and the cause was found by reading a machine's own journal. A push to each of the three machines
|
||||
released it: each new host applied and reported, and the plan moved on.
|
||||
|
||||
The same day, a second way to lose a report showed up. Assigning modules with tools to a workstation
|
||||
changed the bus's user list, which the control machine's declaration carries. Applying it replaced the
|
||||
bus's container, which cut every machine off for about fifteen seconds. The control machine itself
|
||||
then logged `applied, and could not tell the mesh: reporting: nats: connection closed`. The report was
|
||||
lost because the bus restarted under the apply that restarted it.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**Every genuine host upgrade loses one report.**
|
||||
[Issue 163](../163-a-delivered-host-stood-aside-on-every-push-and-reported-nothing/00-report.md) fixed
|
||||
the host that stood aside on every push for the version it already ran. It named the mechanism, that
|
||||
standing aside cancels the context the report is published with. That fix made standing aside
|
||||
happen only for a real new version, but left the mechanism in place. So whenever a host build reaches
|
||||
a machine, that apply's report is lost.
|
||||
|
||||
**And the mesh cannot tell a stuck wait from a slow one.** A plan that waits on a report that will
|
||||
never come waits for ever, and nothing about it changes:
|
||||
|
||||
- its age does not grow ("for 0s");
|
||||
- `late` stays false;
|
||||
- nothing logs, emits an event or alerts.
|
||||
|
||||
This is [issue 187](../187-the-mesh-tells-nobody-when-it-stops-working/00-report.md)'s class of fault
|
||||
again, *the mesh tells nobody when it stops working*, now in the rollout machinery that every merge
|
||||
goes through. The operator's rule from issue 163 applies: if an answer has not come in the time an
|
||||
answer takes, something is wrong, and the mesh must say so itself.
|
||||
|
||||
## What a fix has to settle
|
||||
|
||||
1. **A report survives whatever its own apply restarts.** The host publishes its report and has it
|
||||
acknowledged before it stands aside. It retries a report the bus dropped once the link is back.
|
||||
Failing that, the new host should report the declaration it took over,
|
||||
naming the apply its predecessor finished. A lost report must be impossible, not merely unlikely.
|
||||
2. **A plan's wait has an age and a bound.**
|
||||
- "for 0s" must be the real time since the wait began.
|
||||
- A wait past a bound, set by how long an apply takes rather than by a guess, makes the plan
|
||||
`late`.
|
||||
3. **Late is said where people and agents look.**
|
||||
- It is said in `status` and in `plans`.
|
||||
- It is logged as a warning by the controller.
|
||||
- It is emitted as an event under the controller seat, so something can alert on it.
|
||||
4. **A plan waiting on a machine the mesh has stopped hearing from** says that, by name, instead of
|
||||
waiting. The machine's heartbeat already tells the controller it is alive. A live machine with an
|
||||
unacknowledged declaration is the stuck case itself.
|
||||
|
||||
How each is checked belongs to the fix. For the host: a delivered upgrade, applied, is reported. For
|
||||
the controller: a plan whose machine never reports turns `late` within its bound, and says so in
|
||||
`status`, the log and an event.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in:
|
||||
- mesh-controller
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 231 — A misspelled placeholder is written out as text
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04, while studying the predecessor's failures. A manifest was checked whose one file holds four
|
||||
placeholders: `${shel:zsh:first}` (a misspelled namespace), `${setting:Undeclared}` (a setting the module
|
||||
does not declare), `${machnie:address}` (a misspelled namespace) and `${XDG_CACHE_HOME:-x}` (shell
|
||||
syntax, which must pass through). The catalogue check, which runs the same functions registration does,
|
||||
answered `ok`.
|
||||
|
||||
The controller fills each namespace it knows with its own pattern (`machine`, `setting`, `bound`, `dir`,
|
||||
`port`, `secret`, `environment`, `shell`, …). A word in that shape that no pass consumes is left in the
|
||||
file as it was written. A misspelling therefore reaches a machine as literal text, in a configuration
|
||||
file that then reads it as a value.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
This is exactly the predecessor's failure: an unresolved template variable in a destination path
|
||||
installed green, and a literal `${...}` path stood under `/etc/ssl` until somebody looked. The mesh's
|
||||
namespaced placeholders were meant to end it ([ADR 0164](../../02-DECISIONS/0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md)),
|
||||
and they do for every name spelled right.
|
||||
|
||||
## What a fix has to settle
|
||||
|
||||
After every pass, a final sweep refuses any remaining `${<word>:` whose word is a lower-case
|
||||
namespace-shaped token, naming the module, the field and the token, at the catalogue check and at
|
||||
composition. Shell syntax (`${NAME:-…}`, `${(%):-…}`, `${1:-.}`) is not namespace-shaped and passes. So
|
||||
does contributed shell code, which no pass reads (ADR 0204). A setting a module uses but does not
|
||||
declare is refused the same way.
|
||||
|
||||
How it is checked: the controller's test with the four placeholders above, three refused by name and
|
||||
one passed through.
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in: [mesh-catalog modules/photos]
|
||||
fixed-by: mesh-catalog#264
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 232 — A consumer authenticates against a database its user does not live in, and one fault hid it behind another
|
||||
|
||||
## What was observed
|
||||
|
||||
Fixing [issue 225](../225-a-provisioner-cannot-read-the-grant-secrets-since-its-code-left-the-container/00-report.md)
|
||||
on the control machine, 2026-10-04. With the grant secrets readable again the provisioner created
|
||||
both consumers' users at once, and one consumer still could not connect:
|
||||
|
||||
```
|
||||
Authentication succeeded | user: mesh_novox_invoice | authDb: mesh_novox_invoice
|
||||
Authentication succeeded | user: mesh_novox_photos | authDb: mesh_novox_photos
|
||||
Authentication failed | user: mesh_novox_photos | authDb: admin
|
||||
UserNotFound: Could not find user "mesh_novox_photos" for db "admin"
|
||||
```
|
||||
|
||||
The provider creates each consumer's user **in that consumer's own database**, which is what the
|
||||
first two lines are. `photos` asks for `admin`. Its sibling `invoicing`, against the same
|
||||
provider, asks for `${bound:mongodb-database:as}` — the name the mesh gave it — and works.
|
||||
|
||||
The password was never the problem: the grant secret and the value in the consumer's environment
|
||||
hash identically.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**One fault wore the other's clothes.** While the provisioner could not read its secrets at all,
|
||||
*no* user existed, so `UserNotFound ... for db "admin"` was a true and complete account of issue
|
||||
225. Fixing 225 is what made the wrong database visible — before that, every symptom pointed at
|
||||
the thing that was already broken, and a second fault behind it was indistinguishable.
|
||||
|
||||
That is the general shape worth keeping: **a fault that explains the symptom is not evidence
|
||||
there is only one.** The check is to fix the first and look again, rather than to close both on
|
||||
one explanation.
|
||||
|
||||
It also says something about the interface. Which database a consumer authenticates against is
|
||||
part of what `mongodb-database` means, and it is spelled out by hand in each consumer. Two
|
||||
consumers of one provider wrote two different answers, and only one was right; nothing compared
|
||||
them. That is the shape [issue 124](../124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)
|
||||
records for a derived bucket, here for the authentication database —
|
||||
[ADR 0202](../../02-DECISIONS/0202-a-provider-declares-what-it-derives-for-each-consumer.md)'s
|
||||
mechanism is what would remove it, by letting the provider say it once.
|
||||
|
||||
## Resolved
|
||||
|
||||
`photos` asks for `${bound:mongodb-database:as}`, as `invoicing` already did (mesh-catalog#264).
|
||||
**How it is checked:** the module is rebuilt and pushed, and `photos-server` connects — verified
|
||||
on the control machine rather than inferred from the manifest.
|
||||
|
||||
## What this leaves open
|
||||
|
||||
Nothing compares two consumers' idea of one interface. The provider could serve the
|
||||
authentication database as a derived value under ADR 0202 and neither consumer would state it;
|
||||
that is a candidate for the next consumer of this interface, not a repair of this one.
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in: []
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 233 — A host without its package manager's configuration refuses the declaration that would restore it
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04, on a workstation. The package manager's module writes that tool's main configuration
|
||||
file whole, keeping the file it found. A declaration that no longer named the module
|
||||
([issue 234](../234-a-later-declaration-left-out-four-assigned-modules-and-a-machine-applied-it/00-report.md))
|
||||
reached a host older than the fix that gives a written-over file back its original. The host
|
||||
removed the file, and the kept original stayed where the host keeps such files.
|
||||
|
||||
From the next declaration on, the host refused every declaration whole:
|
||||
|
||||
```
|
||||
refused a declaration: this is the arch host and pacman does not answer here. Either this machine
|
||||
is not Arch, or its package database is broken: pacman exited 1: error: config file … could not be
|
||||
read: No such file or directory
|
||||
```
|
||||
|
||||
The machine stayed in that state for an hour and a half. It retried every five minutes and was
|
||||
refused every time. Two declarations it refused would have repaired it:
|
||||
|
||||
- the current one, which names the package manager's module again and so writes the file;
|
||||
- the one carrying the newer host, which gives a kept original back.
|
||||
|
||||
Neither could be applied. A person restored the kept original by hand, and the next push applied.
|
||||
|
||||
`status` showed the machine as `refused` with the error above. That is correct, but nothing said
|
||||
that the mesh's own tools could no longer reach it.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
The host checks that the package manager answers before it applies anything. That check is right for
|
||||
a machine that is not what the mesh thinks it is. Here the check depends on a file that the mesh's own
|
||||
modules own and can remove. Once that file is gone:
|
||||
|
||||
- every module's change waits behind it, including the host's own upgrade;
|
||||
- the only way back is a person on the machine;
|
||||
- so a fault that one declaration caused, and a later declaration would fix, cannot be undone
|
||||
through the mesh.
|
||||
|
||||
The same shape holds for anything the host probes before applying. If what it probes is one of
|
||||
the mesh's own resources, one bad declaration can wedge the machine.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should a probe that fails refuse the declaration whole? Or should it fail only the resources that
|
||||
need the tool, and apply the rest (files, units, the host's own upgrade)? The rest may include
|
||||
the very resource that restores the tool.
|
||||
2. Should the host refuse to remove a file a seat's holder needs to answer, or warn before it does?
|
||||
3. Is a machine that refuses every declaration for longer than one apply a fault the mesh raises by
|
||||
itself ([issue 187](../187-the-mesh-tells-nobody-when-it-stops-working/00-report.md))? Today it
|
||||
appears only to someone who asks for `status`.
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in: []
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 234 — A later declaration left out four assigned modules, and a machine applied it
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04, on a workstation. Four modules (the package manager's, sudo, localization and the
|
||||
container runtime's) had been assigned and pushed, and the host was applying them in one long apply,
|
||||
about eleven minutes on a loaded machine. Seven more declarations arrived while it worked. When the
|
||||
apply ended, the host logged six times `set aside a declaration: a newer one arrived with it` and
|
||||
applied the one it kept.
|
||||
|
||||
The host chooses by the sequence a declaration carries
|
||||
([issue 107](../107-a-declaration-carries-no-order/00-report.md)), so the one it kept was the latest
|
||||
the controller had sent. That declaration did not name the four modules. Over the next eight minutes
|
||||
the host undeclared all of them:
|
||||
|
||||
```
|
||||
removed sudo.operator (…)
|
||||
removed pacman.config (…)
|
||||
forgotten pacman.package (pacman)
|
||||
removed localization.locale (…)
|
||||
removed docker.prune-service (…)
|
||||
```
|
||||
|
||||
Throughout, the controller's assignments named the four modules on that machine, and they still do:
|
||||
`plan` for the machine lists them. Nobody had unassigned them.
|
||||
|
||||
What happened around it:
|
||||
|
||||
- A catalogue asked the controller to catch up twice in the same minutes, and every builder rebuilt
|
||||
the same catalogue commit.
|
||||
- The controller daemon restarted three minutes after the stale declaration was applied.
|
||||
- Another session was working on the mesh and pushing at the same time.
|
||||
|
||||
The controller logs no send. Which process sent the stale declaration, and from what view, cannot be
|
||||
read back from anything the mesh keeps.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
A declaration is the mesh's word on what a machine should be, and the host applies it in full,
|
||||
including removing what it does not name. A stale one is not harmless: here it removed four modules,
|
||||
and on a host older than the fix for written-over files it deleted four system files outright
|
||||
([issue 233](../233-a-host-without-its-package-managers-configuration-refuses-the-declaration-that-would-restore-it/00-report.md)).
|
||||
|
||||
[Issue 204](../204-a-controller-handover-re-sent-every-node-a-stale-declaration/00-report.md) was this
|
||||
family once before and is marked resolved. Ordering by sequence (issue 107) protects against an old
|
||||
declaration arriving late. It does not protect against a declaration that is new in sequence but
|
||||
composed from an old view.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Where can a declaration be composed with fewer modules than the assignments hold? Candidates:
|
||||
- a send from a process with an out-of-date view;
|
||||
- composition while a module's build is being replaced;
|
||||
- a plan sending what it composed when it was made.
|
||||
2. Should every send be recorded with its sequence and its sender, so `status` can show what a machine
|
||||
was last told and by whom?
|
||||
3. Should a declaration that removes something carry a check the host can verify? For example, the
|
||||
assignment generation it was composed from, so a host refuses one older than the generation it has
|
||||
already applied.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in: []
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 235 — An assignment that cannot be composed is recorded anyway, and the node is one push from leaving the mesh
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04, assigning thirteen desktop modules to a workstation in one `assign`. The controller
|
||||
answered `ok: false`. Its output first confirmed every module (`<node> is assigned <module>`, thirteen
|
||||
times), then repeated the same reason eleven times:
|
||||
|
||||
```
|
||||
<node> is not counted as on the network: it does not resolve: these assignments cannot be applied:
|
||||
- i3status-rust and pacman both declare the package "pacman-contrib"
|
||||
<node> is left out of the rest of the mesh: it does not resolve: …
|
||||
```
|
||||
|
||||
and ended with the node's needs from the control node (artifact store, internal CA, package registry)
|
||||
reported as unmet "because they are not both on the private network".
|
||||
|
||||
The thirteen assignments stayed recorded. `plan` for the node failed until one module was unassigned
|
||||
by hand. Had anything pushed in that window — a person, a plan rolling out a rebuilt module, another
|
||||
session — the mesh would have composed every node without this one on the private network, and this
|
||||
node without its own declaration.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
[ADR 0207](../../02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)
|
||||
says `assign` judges the modules together and refuses what cannot be met. It judges seats. A
|
||||
collision found only at composition — two modules declaring one package, one file, one port — is
|
||||
reported after the assignment is written, as if it were a warning. The result is the most dangerous
|
||||
state a node can be in: assigned, unresolvable, and silently dropped by the next push of anything.
|
||||
|
||||
The reason was also hard to read: one fault, said eleven times, with three follow-on complaints that
|
||||
point at the network instead of at the collision.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should `assign` compose the node with the new assignments before writing them, and refuse the
|
||||
whole call when composition fails? The node would then never become unresolvable through `assign`.
|
||||
2. When a node does not resolve for any reason, should a push of other nodes keep its last composed
|
||||
state in theirs rather than drop it from the private network?
|
||||
3. Should one composition fault be reported once, with the follow-on unmet needs folded under it?
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in: []
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 236 — The catalogue check passes a manifest the host refuses
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04. A login-manager module passed `mesh-controller module check`, was registered, built and
|
||||
assigned. The first push of its declaration was refused whole by the host:
|
||||
|
||||
```
|
||||
refused a declaration: this declaration is refused, and none of it was applied:
|
||||
- resource "lemurs.service": a service that omits state leaves the unit's lifecycle to the
|
||||
machine, and boot and takes-over are both its lifecycle
|
||||
```
|
||||
|
||||
The rule is the host's declaration validation. The controller's check never applies it, so a manifest
|
||||
can pass every check the catalogue has and still fail on the first machine that receives it.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
A refusal is whole, so one module with this fault blocks every other change for that node until the
|
||||
module is fixed, rebuilt and pushed again. The fault is static: it is in the manifest, and could be
|
||||
found before a module is merged. Today it is found by assigning the module to a live machine.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should the host's declaration validation be importable, so that `module check` (and registration)
|
||||
runs it over each resource the manifest declares?
|
||||
2. Or should the controller validate the composed declaration before it sends it, and refuse to send
|
||||
one the host would refuse?
|
||||
3. Which other host-side rules are not visible to the catalogue check today?
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in: []
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 237 — `assign` says a seat is held and that the node does not resolve for lack of it, in one answer
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04. A workstation already ran a module that contributes to `node-hotkeys`, and the module
|
||||
holding that seat was assigned to it. The answer said, in order:
|
||||
|
||||
```
|
||||
<node> is assigned triggerhappy
|
||||
and asus-zephyrus-g14 on <node> now has node-hotkeys held
|
||||
run `push <node>` to send it
|
||||
<node> is left out of the rest of the mesh: it does not resolve: these assignments cannot be applied:
|
||||
- asus-zephyrus-g14 on <node> depends on node-hotkeys, which nothing on <node> holds (novox/hq ADR 0207) — assign one that holds it: triggerhappy
|
||||
```
|
||||
|
||||
Both statements cannot be true. `plan` for the node, run straight after, resolved: it held
|
||||
`node-hotkeys`. A push applied all 300 resources.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
The last lines of an answer are the ones a person and an agent act on. Here they say the node is cut
|
||||
off from the mesh and name the remedy as the assignment just made. A person would assign it again,
|
||||
or stop the rollout. An agent following the instruction loops. The tail of `assign` comes from a
|
||||
second view of the mesh, and that view was not the one the assignment had just changed.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Where does the "left out of the rest of the mesh" judgement read the node's assignments from, and
|
||||
why did it miss the one just recorded: a cached resolution, a read before the write committed, or
|
||||
the catalogue as registered before the contributing module's new version?
|
||||
2. Should an answer that contradicts itself be impossible by construction, with every line of it
|
||||
derived from one resolution taken after the write?
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-04
|
||||
located-in: []
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 238 — The mesh banned its own operator's address for four weeks
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04. An agent working for the operator on a workstation polled the forge's ssh port in a loop,
|
||||
about forty connections in ten minutes, waiting for a branch. The intrusion-prevention holder on the
|
||||
control node banned the operator's home uplink address in the forge's jail for a day, and then in
|
||||
`recidive` for four weeks.
|
||||
|
||||
From then on, nothing in the operator's home could reach the control node on the banned ports: not
|
||||
the workstations, not the laptop. The `unban` verb of `node-intrusion-prevention` lifted it, once the
|
||||
address was found in a ban list of over 400 entries.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
[ADR 0186](../../02-DECISIONS/0186-a-ban-list-never-holds-a-neighbour.md) says a ban list never holds a
|
||||
neighbour. The operator's home address is the one address the mesh can be sure belongs to it. Every
|
||||
machine behind it is a node, and the operator reaches the mesh from it. Yet nothing told the jails
|
||||
so. A ban there locks the mesh out of itself, for longer than any repair takes, and the remedy needs
|
||||
a path that does not go through the banned address.
|
||||
|
||||
The output channel being researched
|
||||
([research 028](../../01-RESEARCH/028-the-meshs-output-channel/00-overview.md)) would not have said
|
||||
anything either: a ban is not reported as an event.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Which addresses are the mesh's own? The public uplink of every node, as each node reports it, and
|
||||
the operator's known addresses. Should every jail's ignore list carry them, derived rather than
|
||||
configured?
|
||||
2. Should a ban of an address any node reports as its own be refused, or at least emitted as an event
|
||||
the output channel carries?
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# 238 — Diagnosis
|
||||
|
||||
## 2026-10-04, from the control node
|
||||
|
||||
**What the forge refused, from the operator's uplink, in the ban's last minute** (the forge's ssh log):
|
||||
|
||||
```
|
||||
14:05:28 Invalid user jochen from <uplink> port 38564
|
||||
14:05:31 Accepted publickey for git from <uplink> port 45156 (the laptop's key)
|
||||
14:05:44 Invalid user jochen from <uplink> port 52074
|
||||
14:05:59 Invalid user jochen from <uplink> port 37978
|
||||
```
|
||||
|
||||
Three refusals in thirty-one seconds — `maxretry = 3` — and the `gitea` jail banned the uplink at
|
||||
14:06:00; `recidive` counted it the same second. Between the refusals the same key logged in as `git`:
|
||||
the agent's own git operations were fine, and the refusals were ssh commands that named no user.
|
||||
|
||||
**Why they named the wrong user.** On the laptop and the workstation, `ssh -G <forge's public name>`
|
||||
resolves to the operator's account and port 22: nothing in the ssh configuration the mesh writes
|
||||
(`ssh-client`, to-be 29) names the forge. A bare `ssh <forge>` — or a git URL without `git@` — presents
|
||||
the login name, which the forge does not have.
|
||||
|
||||
**Why the operator's uplink is bannable at all.** The jails' `ignoreip` (the `fail2ban` module's
|
||||
`jail.local`) holds loopback, the mesh's private range and every private range (ADR 0186). A node at
|
||||
home reaches the control node from the home's public address, which is none of those. Nothing the
|
||||
mesh knows puts it there.
|
||||
|
||||
## What would have stopped it
|
||||
|
||||
1. **The forge in the ssh configuration the mesh writes**: a `Host` block for the forge's public and
|
||||
internal names with `User git` and the forge's ssh port. It cannot be written today: the `git`
|
||||
provision serves the forge's http port only, and the controller translates a served `port` to the
|
||||
machine's published port but no other key (`ServedOn`), so an `ssh-port` would reach a consumer as
|
||||
the container's 22, not the machine's 222.
|
||||
2. **The mesh's own public addresses in every jail's ignore list.** Two sources: each node's public
|
||||
egress as the hub sees it (the tunnel's peer endpoints — every node at home shows the uplink there),
|
||||
or an operator setting naming them. The first is derived and stays true when the uplink changes;
|
||||
the second is a value somebody must remember to edit.
|
||||
|
||||
## Status
|
||||
|
||||
Not located further: both remedies are design choices — (1) a served port the controller translates
|
||||
by listen, (2) the open question 1 of the report.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user