Compare commits
242
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d444458bf6 | ||
|
|
dd8b15a0df | ||
|
|
02b4ca9bec | ||
|
|
7cd5b37afe | ||
|
|
aac0da9c0c | ||
|
|
57b818b669 | ||
|
|
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 | ||
|
|
895c2afad1 | ||
|
|
4b8c5e3b11 | ||
|
|
d362155401 | ||
|
|
557760e537 | ||
|
|
6b1ebd1d4a | ||
|
|
8184585213 | ||
|
|
fa73a17ceb | ||
|
|
08cea893bd | ||
|
|
04625d3e35 | ||
|
|
d7bb24b181 | ||
|
|
23d6e30b8a | ||
|
|
2043e90f35 | ||
|
|
c9418af42e | ||
|
|
9c3e77999e | ||
|
|
6943843fff | ||
|
|
34325d3566 | ||
|
|
fa9e94d863 | ||
|
|
1b34821aa0 | ||
|
|
50ddaf8408 | ||
|
|
f5d518d256 | ||
|
|
577ddf0089 | ||
|
|
13e28e6873 | ||
|
|
6b6ff76a19 | ||
|
|
8d5e6ef76f | ||
|
|
77813f4613 | ||
|
|
4ee8e3905d | ||
|
|
216faec69e | ||
|
|
e36b1a9e9c | ||
|
|
5886969c75 | ||
|
|
5fa43ff755 | ||
|
|
1de4a5f25e | ||
|
|
8a1fa37dce | ||
|
|
44eb13acd0 | ||
|
|
6d53f9168a | ||
|
|
6a1fc71a4c | ||
|
|
fb76fb7256 | ||
|
|
2344bfb69b | ||
|
|
ca8a865e73 | ||
|
|
27c6881287 | ||
|
|
f8458d6f2c | ||
|
|
c5778f4366 | ||
|
|
4c1ad0ed45 | ||
|
|
9873e951a9 | ||
|
|
e5e6e56ecf | ||
|
|
65c30c576a | ||
|
|
ee17cddb74 | ||
|
|
22e96e5bc7 | ||
|
|
7e464e3b22 | ||
|
|
502763ce5e | ||
|
|
eaae0e2b80 | ||
|
|
2ee6eab7b9 | ||
|
|
ce5f85f65e | ||
|
|
911125148c | ||
|
|
d718a917e5 | ||
|
|
a572868333 | ||
|
|
55443b67e6 | ||
|
|
89a202f12e | ||
|
|
b342c9c3da | ||
|
|
336b8c9b62 | ||
|
|
0c82909845 | ||
|
|
d8a0c1b02f | ||
|
|
b232b44f4c | ||
|
|
c03f2cd4c6 | ||
|
|
73c4d24024 | ||
|
|
3372d72da0 | ||
|
|
273b932329 | ||
|
|
f91a3efb6b | ||
|
|
2f0ce4ce19 | ||
|
|
bf3338898e | ||
|
|
4891cdeac5 | ||
|
|
733cff4c3c | ||
|
|
561f26fb34 | ||
|
|
d8a58dadcf | ||
|
|
3f4782cfb4 | ||
|
|
d076647b5d | ||
|
|
ae83c5e09b | ||
|
|
feeea127e2 | ||
|
|
76b563e0ce | ||
|
|
f56686d1e5 | ||
|
|
5938d40dee | ||
|
|
f062672f83 | ||
|
|
e4a0c73e2b | ||
|
|
d1aeee42a4 | ||
|
|
81d780f973 | ||
|
|
3e30846e0f | ||
|
|
b3f18c54c6 | ||
|
|
e11bf320c9 | ||
|
|
da8b4b4ee4 | ||
|
|
c026d5221e | ||
|
|
983fd412c6 | ||
|
|
9ac2493e2c | ||
|
|
560f25c2c7 | ||
|
|
9e0288128b | ||
|
|
709240ec1f | ||
|
|
d57289e049 | ||
|
|
d4a2f99ab5 | ||
|
|
a9f91fdd0c | ||
|
|
131a5e4714 | ||
|
|
329a24fdae | ||
|
|
7f72f3b79a | ||
|
|
114a71f36f | ||
|
|
0f407417f3 | ||
|
|
4af731df19 | ||
|
|
7b1dabbce0 | ||
|
|
2caa5e827b | ||
|
|
179fd7f83f | ||
|
|
d0d5799884 | ||
|
|
9ba4de5557 | ||
|
|
e1203e5a43 | ||
|
|
4ce967619a | ||
|
|
6df2cfecd6 | ||
|
|
73047501f6 | ||
|
|
bf39baf104 | ||
|
|
5eadf36937 | ||
|
|
8c9a2c7501 | ||
|
|
54213ba90c | ||
|
|
7fb59bde98 | ||
|
|
a4d24d7b65 | ||
|
|
116b2d1793 | ||
|
|
68a14493c9 | ||
|
|
98eb3aa76f | ||
|
|
91bbe648a8 | ||
|
|
0e7b85f184 | ||
|
|
dac49de6e7 | ||
|
|
0d9208dbbf | ||
|
|
bf0ee7cb25 | ||
|
|
4567e13071 | ||
|
|
aa5d9f1045 | ||
|
|
79642251a1 | ||
|
|
331cb94c6e | ||
|
|
17ca9a262b | ||
|
|
967c793eaa | ||
|
|
78351560f7 | ||
|
|
62cc2f89c7 | ||
|
|
426f741ad0 | ||
|
|
9bed54d3be | ||
|
|
413daf8ad5 | ||
|
|
5c993c09b7 | ||
|
|
27c1db8a86 | ||
|
|
24aeb203f7 | ||
|
|
afbfd5f29d | ||
|
|
696957aa5e | ||
|
|
3d54fcbb86 | ||
|
|
b13ef1be81 | ||
|
|
f1941304cc |
@@ -131,6 +131,22 @@ def main():
|
||||
else:
|
||||
seen[number] = name
|
||||
|
||||
# And decision records, which 155's fix left out: on 2026-10-02 two ADRs numbered 0169 landed
|
||||
# on main from two sessions within the hour, and every check passed.
|
||||
seen_records = {}
|
||||
for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))):
|
||||
name = os.path.basename(path)
|
||||
number = name.split("-", 1)[0]
|
||||
if not number.isdigit():
|
||||
continue
|
||||
if number in seen_records:
|
||||
bad(os.path.join("02-DECISIONS", name),
|
||||
"is numbered %s, and so is %s -- a record's number is how it is cited. Take the next "
|
||||
"free number across main AND every open pull request; the branch that lands last "
|
||||
"renumbers" % (number, seen_records[number]))
|
||||
else:
|
||||
seen_records[number] = name
|
||||
|
||||
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
|
||||
front = frontmatter(path)
|
||||
if front is None:
|
||||
|
||||
@@ -9,6 +9,11 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
||||
|
||||
- **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is
|
||||
just a machine that has joined; being one implies nothing about what it runs.
|
||||
- **operator account** — the login name of the person who works on a node, stated on the node
|
||||
record; empty for a machine nobody logs into. Everything the mesh places under a person's home is
|
||||
resolved against this account's home and owned by it
|
||||
([ADR 0181](../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
|
||||
Not "the user" (ambiguous with a module's own account) and not a name a definition carries.
|
||||
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one
|
||||
per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs
|
||||
the controller (and, today, the foundation). Lose it and the other nodes keep running what they
|
||||
@@ -73,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
|
||||
@@ -95,3 +106,21 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
||||
A new name for an existing thing lands here first, in the same change that introduces it in code. A
|
||||
record under `02-DECISIONS/` keeps whatever word it was written with — those are immutable — so a
|
||||
term retired here may still appear there, and the mapping above is how to read it.
|
||||
|
||||
## The operator's machine
|
||||
|
||||
- **node tools** — the one tool runtime per node, a host-side process the host supervises, that loads
|
||||
every assigned module's tools bundle and serves every tool and held seat's verb on the subjects the
|
||||
memberships issue; its serving mode on loopback is what was called **the console**
|
||||
([ADR 0175](../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
|
||||
Replaces **"console"** as the module's name; *console* remains the word for the person's end of it.
|
||||
- **bundle** — the artifact a module's own code is built into — its tools, a seat's implementation, a daemon — in any language the mesh has a toolchain for, interpreted or compiled; never an image. One module may declare several ([ADR 0188](../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)).
|
||||
- **kept region** — a marked block in a managed file the mesh writes *into*, where the operator's own
|
||||
lines survive every push and are given back when the module goes
|
||||
([ADR 0174](../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
|
||||
One of the two ways a node varies a module; the other is a **setting**.
|
||||
- **installed / holding** — a module may be assigned (its package installed, its files placed) without
|
||||
holding the seat its family declares; *holding* is being the one — the login shell, the display
|
||||
session — on that node ([ADR 0176](../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)).
|
||||
- ~~flavor~~ — not used. What a flavor varied is a setting or a separate module.
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
status: graduated
|
||||
initiated: 2026-10-02
|
||||
touches:
|
||||
- 02-DECISIONS/0040-what-a-module-is.md
|
||||
- 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
|
||||
- 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
|
||||
- 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
|
||||
- 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
|
||||
- 02-DECISIONS/0161-what-deserves-a-seat.md
|
||||
- 03-DESIGN/01-to-be/05-the-node-host.md
|
||||
- 03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md
|
||||
- 03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md
|
||||
- 03-DESIGN/01-to-be/34-the-console.md
|
||||
- 03-DESIGN/00-as-is/10-module-catalogue.md
|
||||
- 04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md
|
||||
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
|
||||
became:
|
||||
- 03-DESIGN/01-to-be/37-the-operators-machine.md
|
||||
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
|
||||
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
|
||||
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.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
|
||||
---
|
||||
|
||||
# 018 — The operator's machine as modules
|
||||
|
||||
**What.** The mesh owns the whole machine, not only the services on it. Everything a person
|
||||
configures on a node — the login manager, the display server, the window manager, the shell, the
|
||||
terminal, the launcher, the notifier, the audio setup, the boot images, the downloads folder, the
|
||||
agent at the terminal — is a module: a package, the files it owns under `/etc` and under the
|
||||
operator's home, the seat it holds, the tools it serves. One default configuration per module,
|
||||
varied per node only through settings rendered into the file or a kept operator region, never
|
||||
through an edit. The servers take the universal modules (shell, prompt, git, the agent); the
|
||||
workstations take those and the graphical stack, which a capability the machine reports gates.
|
||||
This effort writes that behaviour down, measures what the predecessor's desktop modules actually
|
||||
contain, and settles what the mesh must gain before the first of them can be written.
|
||||
|
||||
**Why.** The predecessor is retired on every node. What it still owned on the two workstations —
|
||||
about thirty modules' worth of dotfiles, user units and `/etc` files — is now owned by nothing:
|
||||
no generator regenerates them, and a fix to one of them is a hand edit that nothing records. The
|
||||
migration scoped these modules out as *the workstation's own environment*, and
|
||||
[to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) names them as the last
|
||||
thing the predecessor was keeping alive. To-be 29 covers one directory, `~/.ssh`, and draws a
|
||||
boundary inside it. The operator wants no boundary: the machine is the mesh's, as far as it makes
|
||||
sense to configure it. That is a wider scope than any design states, and it reaches three records
|
||||
that were written for services: what a module is, where a module's tools run, and what a managed
|
||||
file may be.
|
||||
|
||||
**What it touches.** The module definition ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)),
|
||||
seats and their contracts ([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)),
|
||||
where a module's tools run ([ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md),
|
||||
[ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md),
|
||||
[to-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §6), the host's vocabulary
|
||||
([to-be 05](../../03-DESIGN/01-to-be/05-the-node-host.md)), managed files and settings
|
||||
([ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md),
|
||||
[issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)),
|
||||
and the catalogue's shape ([as-is 10](../../03-DESIGN/00-as-is/10-module-catalogue.md)).
|
||||
|
||||
**Documents.**
|
||||
|
||||
- [01 — The intended behaviour](01-the-intended-behaviour.md): the operator's wish, written as
|
||||
how the mesh behaves, in the mesh's own words.
|
||||
- [02 — What exists, and what is missing](02-what-exists-and-what-is-missing.md): the
|
||||
predecessor's desktop modules measured; which records already say what is wanted; the gaps.
|
||||
- [03 — One tool executor per node](03-one-tool-executor-per-node.md): where a module's tools
|
||||
run. The direction the operator set, the evidence for it, and what it supersedes.
|
||||
- [04 — The seats of the environment](04-the-seats-of-the-environment.md): the roles a machine
|
||||
has once, their candidate contracts, and what gates each.
|
||||
|
||||
**What it had to settle, and where each landed.** *(Graduated 2026-10-02.)*
|
||||
|
||||
1. A module is one *managed thing*, software or not, and every module may serve tools — or ADR
|
||||
0040 already says this and only its examples are narrow.
|
||||
2. One tool executor per node, host-side, module-agnostic; which records it supersedes and
|
||||
in what form the console continues.
|
||||
3. Per-node variation is a setting rendered into the file or a kept region, never an edit —
|
||||
ADR 0011 stands — and issue 168 is fixed before any environment module carries a setting.
|
||||
4. User-scoped units on the host's `service` shape, and a service-manager seat whose holder
|
||||
serves the tools about them.
|
||||
5. The operator account stated on every node; today no node record carries one.
|
||||
6. The seats of the environment and their verbs, one record per seat, slowly, because a
|
||||
seat's tools bind every future holder.
|
||||
7. Where the environment modules live: this catalogue, or one of their own as the media chain
|
||||
has; and whether a third-party organisation's tooling belongs in a public catalogue at all.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 01 — The intended behaviour
|
||||
|
||||
*Written 2026-10-02 from the operator's words, in the mesh's words. What is wanted, before what
|
||||
exists. Where a sentence restates a record, the record is named; where it goes further, that is
|
||||
said.*
|
||||
|
||||
## The machine is the mesh's
|
||||
|
||||
**Everything configurable on a node is declared by a module.** Not only the services the mesh
|
||||
runs: the login manager, the display server, the window manager, the bar, the launcher, the
|
||||
notifier, the compositor, the lock screen, the terminal emulator, the clipboard, the shell and its
|
||||
prompt, the editor, the audio setup, the boot images, the package manager's configuration, the
|
||||
agent a person runs at a terminal, and the folders a person works in — a downloads folder that is
|
||||
tidied, backed up, distributed to other nodes and asked questions of. System folders and the
|
||||
operator's home alike. The operator is the only person on every node, so the mesh manages the
|
||||
person's machine, not a machine with a person on it.
|
||||
|
||||
This is [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)'s definition applied without
|
||||
the service bias its examples carry. A module is one managed thing, named once, described
|
||||
completely by its manifest. It may have a package, files, a container, a unit, a binary, a seat it
|
||||
holds, and tools it serves — any one of these, or all, or two. There is **no kind of module**: zsh
|
||||
has a package, files, a seat claim and the tools that claim obliges it to serve; downloads has a
|
||||
folder, a process and tools; nftables has a package, files, a service, a seat and tools. The
|
||||
difference is what each declares, not what each is.
|
||||
|
||||
**The home has no boundary.** [To-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)
|
||||
owns one directory under the home and draws a line inside it between the mesh's and the person's.
|
||||
Here the line is drawn only by what the modules declare: every file some module places is the
|
||||
mesh's; what no module declares is found and left alone, exactly as the adoption rules already
|
||||
say for a machine. The reach is bounded by sense, not by a rule — the mesh configures what can
|
||||
be configured, and a person's documents, projects and history are data under
|
||||
[ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md), not configuration.
|
||||
|
||||
**A module names no node and no path.** The operator account is a node fact and the home is
|
||||
derived from it ([to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2,
|
||||
shipped in the controller; its record is proposed in an open change). A module places a file
|
||||
*under the home, owned by the account*, and the same manifest lands on a server and a laptop.
|
||||
|
||||
## One default, varied by settings, never by edits
|
||||
|
||||
**One module, one default configuration.** The window manager module ships the configuration
|
||||
that is right for every node. There are no flavors: the predecessor's one desktop module carried
|
||||
four, one per class of machine, and what differed between them is what settings are for.
|
||||
|
||||
**A node varies a module in exactly two ways.** A **setting**, declared by the module with its
|
||||
type, meaning and default (proposed alongside the container-runtime records), set for the mesh
|
||||
or for one node, and rendered into the file at composition — the value is in the file, not in an
|
||||
environment variable the file reads. Or a **kept region**: a block in a file the mesh writes
|
||||
*into*, where the operator's own lines survive every push
|
||||
([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md)). An
|
||||
edit to a managed file outside such a region is not a third way; it is overwritten, as
|
||||
[ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) says, and the
|
||||
predecessor's habit of adopting disk drift back into its database is not carried over.
|
||||
|
||||
The predecessor's theming — some ninety environment variables substituted into templates at sync
|
||||
time, with tools to list and set them — is the same idea with the wrong rendering. The knobs
|
||||
become declared settings; the file carries the value.
|
||||
|
||||
## Roles a machine has once are seats, and seats carry tools
|
||||
|
||||
**A role a machine fills at most once is a node-scoped seat**, declared by a module
|
||||
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md),
|
||||
[ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)): the login shell, the
|
||||
display session, the display server, the terminal emulator, the launcher, the notifier, the
|
||||
compositor, the lock screen, the service manager, the boot loader. Several modules may be able to
|
||||
hold one — zsh, fish and bash can all hold the login shell — and the assignment on each node says
|
||||
which does. Installing a shell is installing software; holding the seat is being *the* shell.
|
||||
|
||||
**A seat's contract is its tools** ([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)).
|
||||
Every holder of the login-shell seat serves `execute`, which takes one string, the command, and
|
||||
runs it on the node the seat is scoped to. Every holder of the boot seat serves "rebuild the boot
|
||||
images", so *"rebuild your boot images"* is a verb addressed to a machine, not a one-off step in
|
||||
a hook. Every holder of the service-manager seat answers for the units on the machine, system and
|
||||
user scope. A module may serve its own tools beside the seat's
|
||||
([ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §2): show the rendered
|
||||
configuration, set a theme value, report status.
|
||||
|
||||
**Any tool may be called from any node.** The operator's statement, and the grant model it
|
||||
implies: the executor on each node may call everything, as the console already may. A verb that
|
||||
needs root on the machine is the module's concern — the tool escalates, the executor and the
|
||||
caller do not know.
|
||||
|
||||
## Servers and workstations differ by capability, not by catalogue
|
||||
|
||||
The same catalogue serves every node. A module declares what it needs — a graphical session, a
|
||||
display server, a container runtime — and the machine reports what it has, as the profile already
|
||||
reports eight capabilities today ([issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)).
|
||||
Assignment refuses the wrong placement by name
|
||||
([ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3). So every node takes the shell,
|
||||
the prompt, git and the agent; only a node with a graphical session can take the display server,
|
||||
and only a node holding the display server can take a window manager. Nothing in a module says
|
||||
"workstation".
|
||||
|
||||
## What the operator would say to the mesh
|
||||
|
||||
*Set the login shell on the build node to fish. Rebuild the laptop's boot images. Show me the
|
||||
window manager's effective configuration on the desktop and where each value comes from. Give
|
||||
the downloads folder on the laptop to the home server. Run `uptime` on every node.* Each of these
|
||||
is a seat verb or a module tool, addressed to a node, answered by whatever holds the role there.
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# 02 — What exists, and what is missing
|
||||
|
||||
*Measured 2026-10-02 on one installation: two workstations, two servers, all four converged to
|
||||
the mesh; the predecessor retired on the last workstation the day before. Numbers are from the
|
||||
machines and the repositories, not from memory.*
|
||||
|
||||
## 1. What the predecessor's desktop looks like
|
||||
|
||||
The predecessor's catalogue on the laptop held **34 modules**, of which **28** are the operator's
|
||||
environment rather than services. By what they declare:
|
||||
|
||||
| shape | count | examples |
|
||||
|---|---|---|
|
||||
| package only | 9 | browser, mail client, process monitor, media player, file manager, chat |
|
||||
| package + `/etc` files + system service | 5 | login manager, display server, power and thermal daemons, package manager configuration |
|
||||
| package + files under the home | 6 | shell and prompt, the agent at the terminal, scripts, the sync client, a music player |
|
||||
| files under the home + user units + hooks | 2 | the desktop environment, audio |
|
||||
| third-party organisation tooling | 6 | out of scope here |
|
||||
|
||||
**The desktop module alone** declares **88 files**, **4 flavors** (the window-manager stack, and
|
||||
one per class of machine), **2 user units** with a hook to enable them, 8 files under `/etc`, a
|
||||
wallpaper shipped as an asset, and reads **about 90 environment variables** as theme knobs,
|
||||
substituted into its templates at sync time and set through a theming tool. Its hook exists
|
||||
because *shipping a unit file does not run it*: one unit had been deployed for months and ran on
|
||||
one machine only, because somebody had enabled it there by hand.
|
||||
|
||||
**The shell module** ships `~/.zshrc`, the prompt configuration, an `~/.ssh/config` that the
|
||||
predecessor generated from its registry, and a `LOGIN_SHELL` variable applied with `chsh` by a
|
||||
hook. Two flavors: the prompt theme, and autocompletion.
|
||||
|
||||
**Other modules write into the desktop module's files.** The chat client places i3 and notifier
|
||||
snippets into `config.d` directories the desktop module owns, and its launch flags, window
|
||||
placement and notification colours are each a variable with a default.
|
||||
|
||||
**One-off steps live in hooks** across the set: enable user units, `chsh`, create a swap file,
|
||||
`mkinitcpio`, enable a vendor VPN service the package ships disabled. Every one is state the
|
||||
host could declare or a verb a seat could serve; none is today.
|
||||
|
||||
## 2. What the migration did with them
|
||||
|
||||
The migration's module to-do scoped the whole set out as *desktop / workstation ricing — the
|
||||
workstation's own environment* and *node/OS tooling — managed on the node, never catalogue*. The
|
||||
last workstation's runbook then split the same set three ways: **A**, system scope, which the
|
||||
host's vocabulary can express today (the login manager, the display server, the power daemons,
|
||||
the package manager, the container runtime); **B**, under a home or a user unit, waiting on
|
||||
to-be 29; **C**, package only, the operator's call. The migration log closes the workstation with
|
||||
*the operator's desktop awaiting its design*.
|
||||
|
||||
Two things followed from scoping them out. Nothing regenerates those files now, so a fix is a hand
|
||||
edit — the login manager's session script was fixed this way on the day of writing, and recorded
|
||||
in a repository nothing deploys from. And the one piece of this family written as a mesh module,
|
||||
the ssh client, was closed on hold in the catalogue until the controller carried the account fact.
|
||||
|
||||
## 3. What the records already give
|
||||
|
||||
| wanted | record | state |
|
||||
|---|---|---|
|
||||
| one module per managed thing; every module may have tools | [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) | accepted; examples are services, and the shell is named as a *shared* seat |
|
||||
| a module declares its own node-scoped seat | [ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) | accepted |
|
||||
| a seat's contract is its tools; a holder may add its own | [ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) | accepted; one node seat serves verbs live |
|
||||
| a capability the machine reports gates a holder | [ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3 | accepted; the profile already reports `graphical-session` |
|
||||
| the account as a node fact; a file under the home owned by it | [to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2 | built in the controller; its record proposed in an open change |
|
||||
| inside a home: owned, written into, written by the module, found | proposed in the same change | proposed |
|
||||
| a setting declared with type, meaning, default and cost | proposed with the container-runtime records | proposed |
|
||||
| a managed file is derived; an edit is overwritten | [ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) | accepted |
|
||||
| the mesh writes into a shared file, never over it | [ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md) | accepted |
|
||||
| a module names no path; the host resolves the home | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) | accepted |
|
||||
| the `user` shape: a login shell is declared state | [to-be 05](../../03-DESIGN/01-to-be/05-the-node-host.md) | designed; used by no module |
|
||||
|
||||
## 4. What is missing
|
||||
|
||||
1. **The account is recorded nowhere.** The node record has the column; on all four nodes it
|
||||
is empty. Every home-scoped module is unassignable until the operator states it.
|
||||
2. **User-scoped units.** The host's `service` shape has no user scope. To-be 29 says it
|
||||
plainly: *a workstation's per-user daemons have no form the mesh can send.* The desktop
|
||||
module's two units, the audio masks, the power module's memory guard and the thermal
|
||||
daemon's profile switcher all need it.
|
||||
3. **One-off steps.** `mkinitcpio`, `chsh`, creating a swap file. Each is either declared
|
||||
state the host lacks a shape for, or a verb a seat should serve. An action in a declaration
|
||||
is refused over the link, and rightly.
|
||||
4. **Settings leak** ([issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)):
|
||||
a setting reaches every mergeable file and every contribution of its module. Ninety theme
|
||||
knobs on that mechanism would reach ninety files. The proposed settings record says a setting
|
||||
names the file it lands in; that has to ship first.
|
||||
5. **Where tools run.** Every module that serves a tool today does so from its own container
|
||||
per node. See [03](03-one-tool-executor-per-node.md).
|
||||
6. **A seat's verbs are undecided for every seat but three.** To-be 33 leaves which verbs each
|
||||
seat serves as *a decision per seat, slowly*. The environment adds a dozen seats.
|
||||
7. **Catalogue placement.** The media chain left this catalogue for its own; whether the
|
||||
environment does the same, and whether a third-party organisation's tooling belongs in a
|
||||
public catalogue, are unasked.
|
||||
@@ -0,0 +1,88 @@
|
||||
# 03 — One tool executor per node
|
||||
|
||||
*The direction the operator set on 2026-10-02, the evidence it rests on, and what it supersedes.
|
||||
A direction, not yet a decision: the record is written when this effort graduates.*
|
||||
|
||||
## Where tools are served today
|
||||
|
||||
[To-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) names three families — a
|
||||
role's tools on the seat, a module's own tools on the module, the mesh's own verbs on the
|
||||
controller seat — and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
||||
says a runtime serves the subjects its membership issues. What *runs* that runtime is
|
||||
[ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md):
|
||||
one supervised process per module, under the module's own account, carrying that module's
|
||||
compiled tools. Measured on the live mesh:
|
||||
|
||||
| who answers | how it runs | count |
|
||||
|---|---|---|
|
||||
| the mesh's own verbs | the controller binary, on its node | 17 verbs |
|
||||
| the store seat | the store's own runtime | 2 verbs |
|
||||
| the packet-filter seat | **a container per node**, built on the tool-runtime base image, with the network namespace and `NET_ADMIN`, on all four nodes | 3 verbs and 1 own tool |
|
||||
| every module's own tools | the module's container, one per node it runs on | 67 tools across the catalogue |
|
||||
| the console | a container per node, loopback MCP, `invokes: *` | serves none, calls all |
|
||||
| the host | — | serves nothing; answers no question about the machine |
|
||||
|
||||
**The packet-filter holder is the case to look at.** The module is a package, three files and a
|
||||
system service. To serve three verbs it also declares a built image and a container on every
|
||||
node whose only job is to answer them. Scaled to the environment — a shell, a prompt, a launcher,
|
||||
a notifier, a compositor, a login manager, a service manager, a boot loader, a downloads folder —
|
||||
that is one container per module per node for software that is itself not a container, and the
|
||||
operator's judgement is that tools should not run inside a container at all.
|
||||
|
||||
## The direction
|
||||
|
||||
**One tool executor per node, on the host side.** A process the host supervises, the way the
|
||||
launcher supervises the host ([ADR 0005](../../02-DECISIONS/0005-the-node-host.md)): not a
|
||||
container, one bus credential for the node, module-agnostic. It loads the tool code of every
|
||||
module assigned to the node and serves each module's tools and each held seat's verbs on the
|
||||
subjects the membership issues — nothing changes in what [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
|
||||
and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
||||
say about subjects, grants and memberships; what changes is that one process subscribes for the
|
||||
node instead of one per module.
|
||||
|
||||
- **A module brings its tools as a built artifact**, a bundle the pipeline produces, never an
|
||||
image. The executor knows bundles and subjects; it knows nothing of zsh or nftables.
|
||||
- **A tool is code the module wrote**, one function behind an MCP verb. `execute` on the shell
|
||||
seat is a function with a string argument. The executor does not declare, template or
|
||||
interpret tools; it runs them.
|
||||
- **Root is the module's concern.** A tool that must change the packet filter or rebuild boot
|
||||
images escalates itself. The executor does not run as root for everyone, and the caller does
|
||||
not know.
|
||||
- **Any node may call any tool on any node.** The executor's credential may call everything,
|
||||
as the console's already does; per-module grants on the calling side are not kept.
|
||||
- **The mesh's own verbs stay with the controller** ([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)),
|
||||
and a mesh-scoped seat's verbs run on the node that holds it
|
||||
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)).
|
||||
No hub is added; the controller's node is already one.
|
||||
|
||||
**The console is the executor, renamed.** It already runs on every node with a credential that
|
||||
may call everything, and it already serves the mesh's tools to whoever is on the machine over
|
||||
MCP on loopback ([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
|
||||
It moves out of its container into the host's process tree, gains the serving half, and takes a
|
||||
name that says what it is — *the node's tool runtime* or simply *node tools*; "console" names
|
||||
the operator's half only.
|
||||
|
||||
## What it supersedes, and what it keeps
|
||||
|
||||
| record | effect |
|
||||
|---|---|
|
||||
| [ADR 0047](../../02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md), [ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md) | superseded *for tools*: one process per node runs every module's tool code, under one account. A module's long-running service — a daemon, a container — is untouched; the executor runs tools, not services. The record must say why one account for every module's tools is acceptable: every tool may be called from every node anyway, and root is taken by the tool, not granted to the process |
|
||||
| [ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md) | kept in substance — a module, assigned per node, loopback MCP, the machine's login is the authority — changed in form: host-side, not a container; serves as well as calls; renamed |
|
||||
| [to-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §6, [to-be 34](../../03-DESIGN/01-to-be/34-the-console.md) | amended the same way |
|
||||
| [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §3, *a container may ask for a capability* | moot for that holder: the verbs run on the host side and escalate as they need |
|
||||
| the container-runtime seat, proposed in an open change: *the holder runs as a supervised process and serves the verbs locally to the host and on the bus* | consistent — a supervised process serving verbs is what the executor is; the open question is whether that holder keeps its own process or serves through the executor like everyone else |
|
||||
| the tool-runtime base image | no longer the way tools reach a node; may remain the way a module's *service* is built |
|
||||
|
||||
## What stays open
|
||||
|
||||
- **The executor's language.** The host is a static Go binary and loads no plugins, so the
|
||||
executor is a sibling process, and its language decides the language of every tool bundle.
|
||||
One decision, taken once.
|
||||
- **How a bundle reaches the node.** An artifact of the module's build, delivered as the host
|
||||
delivers everything else; whether it is a file resource in the declaration or a thing the
|
||||
executor fetches by digest.
|
||||
- **Reload.** A push that adds or upgrades a module's bundle reaches a running executor as a
|
||||
reload, not a restart, or every tool on the node blinks on every push.
|
||||
- **The host's own questions.** [Issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)
|
||||
wants a machine to say more about itself. With an executor on every node, "what is this
|
||||
machine made of" is a seat verb like any other, served there.
|
||||
@@ -0,0 +1,65 @@
|
||||
# 04 — The seats of the environment
|
||||
|
||||
*Candidates, not decisions. To-be 33 says which verbs a seat serves is a decision per seat,
|
||||
taken slowly, because a seat's tools bind every future holder. This document lists the roles the
|
||||
operator's machine has once, who could hold each, what gates it, and a first verb or two — so
|
||||
each record has a starting point.*
|
||||
|
||||
## The rule for what is a seat here
|
||||
|
||||
A role the machine fills **at most once** is a node-scoped seat, declared by the module family
|
||||
that fills it ([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)). A thing
|
||||
several of which coexist without contention — editors, browsers, media players — is not a seat;
|
||||
each is a module with its own tools, and nothing is singular about it. A seat is held by one
|
||||
assignment per node; other modules of the same family may be installed beside it without
|
||||
holding it ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) §1, read with the sharper
|
||||
distinction: *installed* is not *holding*).
|
||||
|
||||
## Candidate seats
|
||||
|
||||
| seat | holders | gated by | first verbs |
|
||||
|---|---|---|---|
|
||||
| **login shell** | zsh, fish, bash | nothing: universal | `execute(command)`; `show-config`; the holding itself sets the account's login shell through the host's `user` shape |
|
||||
| **service manager** | systemd | the `service-manager` capability the profile reports | units: list, status, start, stop, restart, enable, journal; **user scope** on each |
|
||||
| **boot** | grub, systemd-boot | a machine that boots itself (not a container host) | `rebuild-images`; `entries` |
|
||||
| **package manager** | pacman, apt | the `package-manager` capability | search, installed, upgrade, orphans; today a capability the host uses, not a seat anyone holds |
|
||||
| **display server** | xorg, wayland compositors that are their own server | the `graphical-session` capability | `displays`; `layout` |
|
||||
| **display session** | i3, sway | the display server seat held on the node; i3 needs x11, sway needs wayland | `reload`; `workspaces`; `windows`; `move` |
|
||||
| **terminal emulator** | xterm, alacritty, foot | display session | `open`; `font` |
|
||||
| **launcher** | rofi, dmenu | display session | `show`; `theme` |
|
||||
| **notifier** | dunst, mako | display session | `send`; `history`; `rule` |
|
||||
| **compositor** | picom | display server (x11 only) | `restart`; `effects` |
|
||||
| **lock screen** | i3lock, swaylock | display session | `lock` |
|
||||
| **bar** | i3status-rust, waybar | display session | `reload`; `blocks` |
|
||||
| **login manager** | lemurs, greetd | graphical session | `sessions`; `default-session` |
|
||||
| **audio** | pipewire, pulseaudio | the machine reports a sound device | `sinks`, `sources`, `default`, `volume`, `mute` |
|
||||
| **clipboard** | greenclip, cliphist | display session | `history`; `clear` |
|
||||
|
||||
Not seats, modules with their own tools: the editor, the browser, the mail client, the file
|
||||
manager, the media player, the chat client, the agent at the terminal, the downloads folder, the
|
||||
scripts folder, the sync client, the power and thermal daemons that are specific to one machine's
|
||||
hardware.
|
||||
|
||||
## What the table implies
|
||||
|
||||
**Capabilities come first.** `graphical-session`, `service-manager` and `package-manager` are
|
||||
reported today. *A display server is held* is not a capability but a seat being held, and a
|
||||
module that needs it declares a dependency on the seat, not on a capability: *i3 needs the
|
||||
display server seat held by xorg*. Whether a held seat can gate another's assignment is a
|
||||
question for the controller's resolver, and the first environment module after the shell will
|
||||
ask it.
|
||||
|
||||
**The service manager comes early.** Four of the predecessor's modules ship user units, and the
|
||||
executor itself is a unit. User scope on the host's `service` shape is a host change whichever
|
||||
module holds the seat; the seat's holder answers the questions about units, it does not apply
|
||||
them — the host does, as it does for every declared resource.
|
||||
|
||||
**The shell comes first.** Universal, no capability, one verb that is immediately useful on
|
||||
every node, and the `user` shape already makes the login shell declared state. It is the module
|
||||
that proves the pattern: a package, files under the home owned by the account, a seat claim,
|
||||
tools served by the executor, settings for the few things that vary per node, and a kept region
|
||||
for the operator's own lines.
|
||||
|
||||
**The login manager is the first system-scope one**, because it needs nothing new: a package,
|
||||
two files under `/etc`, a service — the same shape the ssh daemon module has today — and the
|
||||
session script it owns is the file that was hand-fixed the day this effort opened.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
status: active
|
||||
initiated: 2026-10-02
|
||||
touches: [lab, the lab module, the catalogue, assignments, settings, the controller's store]
|
||||
became: []
|
||||
---
|
||||
|
||||
# 019 — A warm twin of the running mesh
|
||||
|
||||
## What is being investigated
|
||||
|
||||
Whether the lab can keep a **warm twin of the mesh as it actually runs**: the same machines, carrying
|
||||
the same catalogue, the same assignments and the same settings as the live mesh, raised once and kept
|
||||
ready, so that a change can be tested against the mesh as it is rather than against a scenario
|
||||
written to resemble it. A run against the twin would go through the lab module like any other run:
|
||||
a branch per repository, the twin restored from its snapshot, the change applied, the beds run.
|
||||
|
||||
## Why
|
||||
|
||||
The lab's beds raise meshes from declarations written for the bed. They prove the mechanism. They
|
||||
do not prove that a change works on the mesh that runs, with its accumulated assignments, its
|
||||
operator settings, its adopted machines and its modules in their real combinations. The gap showed
|
||||
on 2026-10-02:
|
||||
|
||||
- a change to how a module's settings reach its files was correct in every bed, and would have put a
|
||||
setting into the container runtime's configuration on every machine running that module. Only the
|
||||
composed plan for a real machine showed it;
|
||||
- a firewall change composed cleanly and still left one machine's wired port unfiltered, because
|
||||
of a link that machine had and no bed did;
|
||||
- a recovery step was needed on every machine at once, after a change that every bed had passed.
|
||||
|
||||
The lab already has a warm mode, a snapshot of a raised scenario restored between attempts. What it
|
||||
does not have is a scenario that **is** the running mesh, kept current with it.
|
||||
|
||||
## What it touches
|
||||
|
||||
- **What a twin is made of.** The catalogue and the assignments are records; settings are records;
|
||||
secrets are sealed to machines and cannot be copied. Which of these can be carried to the lab as
|
||||
they are, which must be substituted, and how a twin says what it substituted.
|
||||
- **Data.** A twin with the real catalogue and no real data proves composition and delivery, not a
|
||||
migration. Whether a twin carries data, a sample of it, or none.
|
||||
- **Keeping it current.** A twin raised once goes stale with the first merge. Whether it is
|
||||
re-derived from the live records on each run, refreshed on a schedule, or rebuilt only when asked.
|
||||
- **Machines.** The live mesh has machines of different kinds: a server on the internet, machines
|
||||
behind a home router, a laptop that sleeps. Which of their properties a twin must reproduce for a
|
||||
test to mean anything (reachability, the private network, the found firewall).
|
||||
- **Cost.** The lab machine's memory and disk, and how long a twin takes to raise from cold.
|
||||
- **The lab module's tools.** A run against the twin rather than a named bed: one more tool, or an
|
||||
argument to the run tool.
|
||||
|
||||
## Starting point
|
||||
|
||||
The lab module (ADR 0172) runs beds through the mesh, and the lab's warm mode already snapshots and
|
||||
restores a raised scenario. The beds that raise a machine shaped like one live machine from the
|
||||
catalogue are the nearest existing thing, and the first to compare against.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
status: graduated
|
||||
initiated: 2026-10-03
|
||||
touches: [the tool runtime, the catalogue's tool bundles, the controller's declaration composer, settings, own secrets, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
|
||||
became: [02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
|
||||
---
|
||||
|
||||
# 020 — What a bundled tool is given
|
||||
|
||||
## What is being investigated
|
||||
|
||||
How a module's tools, once they are a bundle the node's runtime loads
|
||||
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
|
||||
[ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)),
|
||||
learn the things their container used to be handed: where the module's configuration file is, where
|
||||
its token or password is, which port the service listens on, where a provision's address is written.
|
||||
A container is given these as an environment and mounts, composed by the mesh per module per machine.
|
||||
A bundle has no environment of its own: the runtime's process carries four words for every bundle it
|
||||
loads, and nothing per module ([design 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4).
|
||||
|
||||
## Why
|
||||
|
||||
Two holders moved on 2026-10-03 — the packet filter and the intrusion prevention — and both could,
|
||||
because neither needs anything but a fixed path and root. Of the thirty-three modules whose tools
|
||||
still run as containers on the runtime's image, thirty-one are not like that: their environment
|
||||
names a configuration file, a credential file, a service address, a grants directory. Moving them
|
||||
one by one without a rule for this would give the mesh thirty-one answers to one question. The
|
||||
measurement and the options are in [01](01-what-the-containers-are-given.md).
|
||||
|
||||
## What it touches
|
||||
|
||||
The runtime (which hands a bundle what it is given), the composer (which resolves `${dir:…}` and
|
||||
`${port:…}` for a container today and would for a bundle), the manifest (where a bundle would say
|
||||
what it needs), and design 38, which records the gap and must say the rule once there is one.
|
||||
@@ -0,0 +1,86 @@
|
||||
# What the tool containers are given, measured
|
||||
|
||||
Counted 2026-10-03 in the catalogue, after the two holders moved.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| modules whose tools still run as a container on the runtime's image | 33 |
|
||||
| tool containers among them (two modules run two) | 36 |
|
||||
| modules whose container's environment carries only the bus credential | 1 (the intrusion prevention, now moved) |
|
||||
| modules whose container's environment carries more | 32 — 31 still containers |
|
||||
|
||||
## What "more" is
|
||||
|
||||
Every value a container is given is one of five shapes. The reference kinds the composer resolves
|
||||
in those values, over the 36 containers: a module directory (`${dir:…}`) in all 36, a mesh-chosen
|
||||
port (`${port:…}`) in 12, a seat and an access grant once each.
|
||||
|
||||
1. **A file the mesh already places on the host, mounted in.** The module's configuration as
|
||||
JSON (`…_CONFIG_FILE`), its own secret (`…_TOKEN_FILE`, `…_PASSWORD_FILE`, `MESH_BROKER_FILE`),
|
||||
a provision's address and secret written for it. Every one is a path under one of the module's
|
||||
directories — its mesh state, its state, its grants, what it has written — mounted at a path of
|
||||
the container's choosing and named to the tool through the environment. **The file is on the
|
||||
host already; only the name under which the tool finds it is the container's.**
|
||||
2. **The service's address, with the port the mesh chose:** `http://127.0.0.1:${port:3000}`. The
|
||||
port is the composer's; the rest is the manifest's constant.
|
||||
3. **A provision's address as a constant string** (a database's URL on the module's own network
|
||||
name), paired with a mounted secret file from shape 1.
|
||||
4. **A directory of grants** (`MESH_RECEIVES`): shape 1 again, a directory rather than a file.
|
||||
5. **Literals the image needs:** a time zone, a user id, a memory limit. These belong to the
|
||||
service's container where one exists; a tool bundle needs none of them.
|
||||
|
||||
So the whole of what a bundled tool needs is: the paths of its module's directories on this
|
||||
machine, the ports the mesh chose for its module here, and the constants its own manifest wrote.
|
||||
Nothing a container had that a bundle cannot have; the mesh composes all three for the container
|
||||
today, per module per machine.
|
||||
|
||||
## What the runtime already has for it
|
||||
|
||||
- The SDK's tool contributor is `(env) => tools`, and `collectTools(env)` takes the environment to
|
||||
hand each contributor. The runtime calls it without one, so every contributor reads the process's
|
||||
— the four words. The hook for a per-module environment exists and is unused.
|
||||
- A launched bundle ([ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md))
|
||||
is spawned with the runtime's environment; the launch takes an environment argument.
|
||||
- The composer resolves `${dir:…}` and `${port:…}` for a container's `env` and `volumes`; the same
|
||||
resolution over a bundle's declaration is the same code.
|
||||
|
||||
## Options
|
||||
|
||||
**A. The bundle declares its environment on its artifact, and the mesh composes it as a
|
||||
container's.** The manifest's tools artifact gains `env`, resolved with the same references;
|
||||
values that were mount targets become the host-side paths directly (`${dir:mesh-state}/config.json`
|
||||
rather than `/run/config/config.json`). The controller composes one environment per bundle per
|
||||
machine into the runtime's declaration; the runtime hands it to the bundle's contributor and to a
|
||||
launched child, and to nothing else. *For:* the tool code does not change — it reads the same
|
||||
names; the conversion of the thirty-one is a mechanical move of the container's `env` with the
|
||||
mounts folded in; one rule, one place. *Against:* the runtime's process carries thirty-one
|
||||
environments in its declaration, and a bundle's environment is visible to the other bundles in the
|
||||
process unless the runtime keeps them apart, which it must — a tool that reads `process.env`
|
||||
instead of the environment it was handed would see its neighbours' paths.
|
||||
|
||||
**B. The runtime derives the environment from the module's placed manifest.** No new field: the
|
||||
runtime reads, for each module it serves, where that module's directories and ports are, and hands
|
||||
a conventional set of words. *For:* nothing to declare. *Against:* a convention the tool code must
|
||||
be rewritten to, thirty-one times; the runtime learns the composer's job; a module that names its
|
||||
file `config.json` and one that names it `settings.json` need different words anyway.
|
||||
|
||||
**C. Tools read their module's files through the bus** — ask the controller. *Against:* a tool
|
||||
that cannot start without the bus answering a question is a tool that fails in the one case the
|
||||
tools exist for, and a secret crossing the bus to reach a file already on the machine is a
|
||||
disclosure for nothing.
|
||||
|
||||
A is the one that keeps the tool code and the composer's vocabulary as they are, and names the one
|
||||
thing the runtime must add: an environment per bundle, kept apart. The thing to decide beside it:
|
||||
whether a bundle's environment may name a secret file at all, or whether secrets stay mounts in
|
||||
spirit — a path the tool reads, never a value in the environment — which is what every container
|
||||
does today and what A keeps if the rule says *paths, not values*.
|
||||
|
||||
## What a decision would have to say
|
||||
|
||||
- Where a bundle says what it is given (the artifact, option A), and that values are paths and
|
||||
constants, never a secret's content.
|
||||
- That the composer resolves it with the references it already has, per module per machine.
|
||||
- That the runtime hands each bundle its own environment and nothing of another's, and how that is
|
||||
checked: a test loading two bundles whose environments differ and asserting each sees only its own.
|
||||
- That the thirty-one move in one mechanical change after the rule lands, each proven by its tools
|
||||
answering from the runtime, and the registration gate then refuses the container shape for all.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
status: graduated
|
||||
initiated: 2026-10-03
|
||||
touches: [the console, 03-DESIGN/01-to-be/34-the-console.md, the tool runtime, seats, assignments]
|
||||
became: [02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md, 03-DESIGN/01-to-be/34-the-console.md]
|
||||
---
|
||||
|
||||
# 021 — Finding a tool in the mesh
|
||||
|
||||
## What was investigated
|
||||
|
||||
How an agent finds the one tool it needs among everything the mesh answers, and how a call names
|
||||
exactly what it asks — a role the mesh holds once, a role every machine holds, or one assignment of a
|
||||
module on one machine — rather than receiving the whole catalogue and a name that can mean several
|
||||
things.
|
||||
|
||||
## Why
|
||||
|
||||
The operator's observation on 2026-10-03: *Claude should not see all tools at once; they should be
|
||||
discoverable — and `postgres.list_databases` is wrong, asking one machine's postgres is not asking
|
||||
another's.* Measured the same day from the console's own answer:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| tools announced to every session at its start | 228, in 110 KB |
|
||||
| names (module or seat prefixes) | 43 |
|
||||
| node seats' verbs, which require `node` | 22 |
|
||||
| modules with tools on more than one machine | 4 — fail2ban, nftables (every machine), postgres, mssql (two each) |
|
||||
| modules reported "not answering", most with no tools and several retired | 47 |
|
||||
|
||||
The two stateful modules on two machines are listed **once**, with `node` optional and *whichever
|
||||
answers* when it is left out — though their two instances hold different databases. Design 34 §3 says
|
||||
such a module is listed once per machine; the live console does not do that. The list is taken once
|
||||
per session, so a tool that arrives later is invisible until the client reconnects. And only Claude
|
||||
Code's own deferral of long tool lists keeps the 228 from the model's context; another MCP client
|
||||
would receive them whole.
|
||||
|
||||
## Options
|
||||
|
||||
1. **Keep the flat list; rely on the client to defer it.** Rejected: a property of one client, and it
|
||||
leaves the ambiguity and the stale list.
|
||||
2. **One flat tool per assignment** (`ace_postgres_list_databases`). Removes the ambiguity, multiplies
|
||||
the list, and runs into the API's tool-name limit (letters, digits, `_`, `-`, 64 characters).
|
||||
3. **A small fixed set of tools that walk the mesh's own structure**, with the full address as an
|
||||
argument: the mesh's seats; a machine's node seats and assignments; a search; a description; a
|
||||
call. Chosen — see [ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md).
|
||||
4. **MCP resources or prompts for discovery.** Clients support them unevenly, and an agent acts
|
||||
through tools; a resource it cannot be relied on to read is not a discovery path.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
status: graduated
|
||||
initiated: 2026-10-03
|
||||
touches: [the tool runtime, the per-module containers, the SDK, the bus grants, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
|
||||
became: [02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
|
||||
---
|
||||
|
||||
# 022 — Where a module's long-running code runs
|
||||
|
||||
## What was investigated
|
||||
|
||||
Twenty-three modules still run their own code in a container built on the runtime's image. Their tools
|
||||
can move as bundles ([ADR 0192](../../02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md),
|
||||
[ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md));
|
||||
the rest of what those containers run cannot yet. This asks where that code goes and how it reaches
|
||||
what its container handed it.
|
||||
|
||||
## What that code is, measured 2026-10-03
|
||||
|
||||
| | modules |
|
||||
|---|---|
|
||||
| subscribes to events on the bus | audit-logger (everything), mesh-catalog (two seat events), mesh-vault, records (`gitea.pull.merged`), and postgres, mongodb, mssql, redis, mosquitto logging their own lifecycle |
|
||||
| provisioners: read the grants the mesh delivered as files, act on the backend, emit | 12 |
|
||||
| a run-once preparation step | mesh-catalog |
|
||||
| a command-line client of the backend | psql, mosquitto_ctrl, git (packages on every machine's system); mongosh, sqlcmd (not in its repositories) |
|
||||
| a service reached by a container name | icecast, mailu-admin, minio, mongodb-server, mssql |
|
||||
| a main of its own | anthropic-consumer, openai-consumer, route-adapter |
|
||||
|
||||
A provisioner needs nothing a launched bundle lacks: files named by its words, its backend, and an emit
|
||||
that already travels through the runtime. The one thing missing is **a subscription** — events
|
||||
delivered to the module's code, acknowledged when it has handled them.
|
||||
|
||||
## Options
|
||||
|
||||
1. **The runtime launches it and is its bus**: the stdio channel gains a subscription; the runtime
|
||||
binds the module's durable consumer and delivers each event to the child, acknowledging when the
|
||||
child answers. One bus connection per machine; any language. Chosen.
|
||||
2. **A process per module with its own bus client and credential.** Every language's SDK would carry
|
||||
a transport and every module a credential on disk — what ADR 0188 rejected for tools, for the same
|
||||
reasons.
|
||||
3. **Keep the containers for this code.** Leaves ADR 0188's rule broken for 23 modules indefinitely.
|
||||
@@ -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.
|
||||
@@ -8,6 +8,8 @@ reconstructed: false
|
||||
|
||||
# 39. What the SDK holds, and what it refuses
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** The test of this record — frequent *and* cascading does not belong — stands and now applies to one SDK per language. Where *what it holds* names the broker client and the event consumer, read: the local protocol a tools bundle speaks to the node's runtime; the transport lives in the runtime and in no SDK, which is what keeps a bus change from rebuilding any module in any language.
|
||||
|
||||
_Reconciliation note (2026-09-05): supersedes the earlier "repository structure" decision, which the consolidation folded; no standalone record remains to point at, so body references to it now point at the nearest surviving record, [ADR 0015](0015-applications-live-in-their-own-repository.md)._
|
||||
|
||||
## Context
|
||||
|
||||
@@ -78,6 +78,8 @@ capability. The host hardcodes no firewall, supervisor, package manager or runti
|
||||
generic apply primitives and platform detection, so it runs where none of those exist — an Android
|
||||
phone has no ufw, systemd, pacman or Docker.
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md).** The shell example above — *bash, zsh and fish all join `shell`; one may be default* — is read as *installed is not holding*: the three may all be installed, and the `login-shell` seat is node-scoped and held by exactly one. The decision — what a module is, and the three relationships — stands; [ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) applies it to the operator's whole machine.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Supersedes the earlier "grouped by domain" decision** (folded in consolidation; see the
|
||||
|
||||
@@ -9,6 +9,8 @@ extends: 0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
|
||||
|
||||
# 47. A module runs its code as its own process, with its own account
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** A module's *tools* are no longer served by the module's own process under its own account: one tool runtime per node, on the host side, serves every assigned module's bundle. A tool is still served on its own subject and only the module that serves it answers; what moved is the process and the account.
|
||||
|
||||
## Context
|
||||
|
||||
A module is one self-contained thing ([ADR 0040](0040-what-a-module-is.md)), and it gets a broker
|
||||
|
||||
@@ -204,3 +204,14 @@ only, the refresh token only, the manager node only.**
|
||||
record extends, amended to describe the adapter generalisation.
|
||||
- The read-only vendor-agnostic analysis, 2026-09-05 (code workspace) — the inventory and the decisions
|
||||
taken on the open questions this record encodes.
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md).**
|
||||
> What stands: `model-access` is one vendor-blind provision for the consumers that do not care which
|
||||
> vendor answers; a vendor's lifecycle is an adapter's; the carve-out that one node holds a refreshable
|
||||
> grant's refresh token readably. What moved: the Anthropic adapter is no longer a part of the
|
||||
> controller's licences context but a module, `claude-licence-manager`, holding the seat
|
||||
> `anthropic-licence-manager`, with the grants in its own store encrypted with a key the vault made for
|
||||
> it; and the agent at a terminal is not a consumer of `model-access` — it is coupled to an Anthropic
|
||||
> grant and uses the seat. The consequence above that the three binding columns *become three ordinary
|
||||
> consumers of `model-access`* therefore no longer describes the agent's bindings; they are the
|
||||
> manager's. The static-key adapters and the vendor-blind records stay where this record put them.
|
||||
|
||||
@@ -9,6 +9,13 @@ extends: 0007-connectivity.md
|
||||
|
||||
# 66. Public routing is name-agnostic, its names are resolved inside the mesh, and an internal authority can certify them
|
||||
|
||||
> **Narrowed, not replaced — 2026-10-03.** One clause of the decision below no longer holds: *publishing
|
||||
> a granted name into internal resolution, mesh-wide*. A public name now resolves publicly, and only
|
||||
> names under the mesh's own suffix get a private answer — [ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md).
|
||||
> Inside the mesh a route is reached and certified by its internal name
|
||||
> ([ADR 0151](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md)). The label, the
|
||||
> node's public domain and their composition stand as decided here.
|
||||
|
||||
## Context
|
||||
|
||||
**[ADR 0007](0007-connectivity.md) and [connectivity §3](../03-DESIGN/01-to-be/08-connectivity.md)
|
||||
|
||||
@@ -283,3 +283,13 @@ modules in the catalogue require it — so a shared secret is a requirement answ
|
||||
which is what this record asks for. Private keys are still made where they are used and never
|
||||
travel, which is the other half and was never in question.
|
||||
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md).**
|
||||
> What stands: every shared secret the mesh makes is the vault's, a private key is made where it is
|
||||
> used, and a long-lived value a backend issues enters the vault's custody — here as the key the vault
|
||||
> makes for the licence manager, which encrypts the vendor's grants at rest with it. What this record
|
||||
> did not foresee: a credential that lives hours, issued by a vendor to the one module that holds its
|
||||
> grant, and handed by that module to the agent on each node sealed to that node's module key, on
|
||||
> request/reply over the bus, never through the vault and never as a file the host writes. ADR 0183
|
||||
> states that as a bounded exception — one vendor, tokens that live hours, one recipient per message —
|
||||
> and a second such channel is a decision of its own.
|
||||
|
||||
@@ -9,6 +9,14 @@ extends: 0110-a-seat-is-a-module-assignment-from-a-closed-set.md
|
||||
|
||||
# 121. A system seat is named for its scope, and a module may define its own
|
||||
|
||||
> **Narrowed, not replaced — 2026-10-03.** *"`the-dns-port` → `node-dns-resolver`"* no longer holds:
|
||||
> the serving role moves to mesh scope as `mesh-resolver`, one per mesh, and `node-dns-resolver` is
|
||||
> retired ([ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)). The
|
||||
> distinction this record kept — serving and asking are two roles, two seats — stands, and
|
||||
> `node-resolver-config` is unchanged.
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0190](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md).** The naming rule stands. The build role this record made mesh-scoped — *the mesh's single build machine* — is node-scoped now: `node-build-agent`, one holder per machine, every holder taking from one work queue.
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) made seats a closed set the
|
||||
|
||||
@@ -9,6 +9,10 @@ extends: 02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-ow
|
||||
|
||||
# 150. A module's own code runs as supervised processes under the module's one account
|
||||
|
||||
> **Widened — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** A module's long-lived process is a bundle in any language the mesh has a toolchain for, run as a unit the host writes; this record never said one language and never meant one, and 0188 says so as the rule.
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** For a module's *tools*, read that record: one runtime per node, the node's one account, bundles loaded from the memberships. This record still governs a module's long-lived processes — a daemon, a provisioner, a scheduled ingest — and the account invariant for them.
|
||||
|
||||
## Context
|
||||
|
||||
The repository answers "what runs a module's own code" two ways and reconciles them nowhere
|
||||
|
||||
@@ -9,6 +9,11 @@ extends: 02-DECISIONS/0066-public-routing-is-name-agnostic.md
|
||||
|
||||
# 151. A route's internal name is composed under the node that serves it
|
||||
|
||||
> **Narrowed, not replaced — 2026-10-03.** *"The roster publishes it as itself, once, at the serving
|
||||
> node's address"* no longer holds: a public name is never given a private answer, and resolves publicly
|
||||
> ([ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)). The internal name this record
|
||||
> composes is what that rests on, and stands.
|
||||
|
||||
## Context
|
||||
|
||||
A module that requires a route is given two names from one label: a public one, `<label>.<public
|
||||
|
||||
@@ -111,6 +111,8 @@ operator owns; narrowing what it may call is a setting on its assignment, which
|
||||
[ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) already provides for
|
||||
and nothing here builds.
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** The surface stays a module assigned per node, on loopback, with the machine's login as the authority. It is no longer a container: it is the node tools runtime's serving mode, host-side, and that runtime also serves every assigned module's tools. The module is renamed `node-tools`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The way a person drives the mesh is inside the mesh.** It is declared, delivered, replaced and
|
||||
|
||||
@@ -94,6 +94,13 @@ itself restarts, and the console says so rather than hiding the modules' tools w
|
||||
**A grant of `*` reaches a role's tools; `seat:<seat>.<verb>` grants one.** The console's `*` needed no
|
||||
change to reach the mesh's verbs, which is what a grant meaning *every tool* should mean.
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).**
|
||||
> The seat gains a generic verb beside the named ones: `command`, which takes one command line as the
|
||||
> controller's binary takes it and answers what it printed. The named verbs stand and keep their
|
||||
> schemas; `command` is the whole binary, added because the operator decided any node may call any
|
||||
> tool and a verb per command was the only thing keeping `node account`, `node show` and the rest
|
||||
> behind a shell on the control node. Additive within the version, as §"additive" above allows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The console answers the mesh's own questions.** Issue 147's first paragraph closes: what a node
|
||||
|
||||
@@ -9,6 +9,8 @@ extends: 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
|
||||
|
||||
# 162. A merge produces a tiered plan the mesh keeps, and a module's dependencies are one relation in the catalogue
|
||||
|
||||
> **Progressive insight — 2026-10-02.** The context below says a dependent is *built by whichever build machine is running — the only one there could be*. That was a fact of the day, not of the decision: since [ADR 0190](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md) a tier's asks are taken by every machine holding the build seat. The plan and its tiers are unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
A merge on the forge reaches the controller as an event, and the controller asks the build
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md
|
||||
---
|
||||
|
||||
# 164. A setting is declared with its default, its meaning and what changing it costs
|
||||
|
||||
## Context
|
||||
|
||||
The operator asked for one thing for every module, with the container runtime as the first case: **one
|
||||
consistent default configuration for every machine, overridable per assignment, and easy to change
|
||||
later.** The four machines' runtime configurations were each written by hand and differ — one keeps
|
||||
running containers through a daemon restart and one does not, their log rotation differs, and each
|
||||
names its resolver and its trusted registries in its own words.
|
||||
|
||||
Most of this was already decided.
|
||||
[ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) said the definition is
|
||||
identity and **defaults**, the assignment's settings are the configuration, *unset is the default*, and
|
||||
*an unknown setting is refused*. [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) made
|
||||
a setting an operator requirement whose contract is "a type and, optionally, a default", answered by
|
||||
"the assignment's, or the requirement's default, or unresolved". An assignment is a module on a node,
|
||||
so the node layer of a module's settings already *is* the per-assignment override, and the mesh-wide
|
||||
layer is the one consistent default a person changes once.
|
||||
|
||||
What was built is narrower than what was decided, measured in the controller on the day of deciding:
|
||||
|
||||
- **Nothing declares which keys are settable.** A mergeable file's content is its defaults, and every
|
||||
key of it — and every key not in it — is accepted. Nothing tells a person, or the console, what can be
|
||||
set, of what type, or what it means.
|
||||
- **The refusal of an unknown setting is not there for most modules.** The stray-setting report returns
|
||||
nothing at all for a module with any mergeable file, because such a file "takes any key"
|
||||
([issue 173](../04-ISSUES/173-a-modules-settings-reach-every-fact-it-contributes/00-report.md) left
|
||||
files that way on purpose). It reports rather than refuses where it does run.
|
||||
- **A value in a file that is not JSON can have no default.** `${setting:<key>}`
|
||||
([ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md)) is refused when no
|
||||
layer sets it — right for a mail domain, where a default is the very literal 0155 removes, and wrong
|
||||
for a tunable like the resolver's upstreams, which the resolver module therefore carries as literals
|
||||
in its file.
|
||||
- **A setting reaches every mergeable file its module owns.** The layers are one flat map per module,
|
||||
laid over each such file. Adding a setting to the resolver module for its own configuration put the
|
||||
key into the container runtime's file as well — the resolver writes into that file too — and the
|
||||
runtime refuses keys it does not know. The plan showed it before any push; the runtime's file was
|
||||
then made to take no settings at all ([issue 198](../04-ISSUES/198-the-lans-dns-server-ran-outside-the-mesh-and-its-filter-closed-it/00-report.md)). Issue 173 stopped settings leaking into
|
||||
contributions and served facts; between one module's own files the leak remains.
|
||||
- **What a change costs is said per file, not per key.** A service names the files it is reloaded or
|
||||
restarted on. The runtime re-reads its trusted registries on a reload and its `dns` key only when it
|
||||
starts; the resolver module declared a reload, so on two machines the key was written, reloaded,
|
||||
and never read, and every container got a public resolver for weeks while everything read as
|
||||
current ([issue 110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/01-resolution.md)).
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Leave settings implicit; document each module's keys in its README.** Rejected: a key the mesh
|
||||
does not know cannot be refused, typed, listed by the console or costed, and a README is a rule
|
||||
enforced by nothing.
|
||||
2. **A second mechanism for tunables beside settings** — defaults in a new block, settings untouched.
|
||||
Rejected: two ways to state one person's value, and design 27 already retires six mechanisms
|
||||
that grew that way.
|
||||
3. **Settings declared in the definition, as 0112's operator requirement: a key, a type, a meaning,
|
||||
optionally a default, and what a change costs.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**A module declares every setting it takes.** Each declared setting has a name, a type, one sentence
|
||||
of meaning, optionally a default, and what a change to it costs. The spelling is design 27's to settle
|
||||
with the rest of the requirement form; this record decides the content.
|
||||
|
||||
**A setting with a default is a tunable; a setting without one is the operator's.** A tunable resolves
|
||||
to its default when no layer sets it — wherever it is read, a mergeable file or `${setting:<key>}` in a
|
||||
file of any format. A setting with no default is refused by name when nothing sets it, as 0155 decided;
|
||||
0155's refusal is narrowed to exactly that case, not changed for it. Whether a value has a default is a
|
||||
fact about the software (a log size does, a mail domain does not), and the definition states it once.
|
||||
|
||||
**The layers stay as they are, and every value says where it came from.** The definition's default,
|
||||
then the mesh-wide layer, then the node's — later wins, objects merge, lists replace. One consistent
|
||||
configuration for every machine is the default plus the mesh-wide layer; one machine that differs says
|
||||
so in its own layer and nothing else. Asked for a module's configuration on a machine, the mesh lists
|
||||
every declared setting with its effective value and its source: *default*, *mesh*, or *node*.
|
||||
|
||||
**Changing later is changing one of three places, and the plan shows its reach before anything moves.**
|
||||
A new default ships with the module's next version and reaches every assignment that does not override
|
||||
it; a mesh-wide setting reaches every assignment of the module; a node's reaches one. The plan of a
|
||||
change names each assignment whose effective value moves.
|
||||
|
||||
**A declared setting says where it lands.** Each names the file or files of its module that read it,
|
||||
and reaches no other: a module that owns two mergeable files no longer has one flat map laid over both.
|
||||
A file that names no setting takes none.
|
||||
|
||||
**A declared setting is the only kind accepted.** Setting a key the module does not declare is refused
|
||||
when it is set, naming the declared keys, rather than reported when the machine is planned. The mesh's
|
||||
own words — where a port, a directory or an operator's data is placed, how far an endpoint reaches —
|
||||
are the mesh's to validate as they are today, and no module declares them. A module
|
||||
that declares no settings keeps today's behaviour until it does; a catalogue test lists those modules,
|
||||
and the list shrinks to empty before the implicit form is removed — design 27's rule for every retired
|
||||
mechanism.
|
||||
|
||||
**A setting says what it costs: nothing, a reload, or a restart.** When a file changes, the host
|
||||
applies the strongest cost among the settings whose values moved in it, so a key the software reads
|
||||
only at start can no longer be written and never read. A setting that reaches a container's environment
|
||||
costs that container being recreated, which the host already does when a container's specification
|
||||
changes; it needs no declaration. A service's `reload-on` and `restart-on` keep
|
||||
naming the files that are not settings — a generated roster, a credential.
|
||||
|
||||
**The container runtime is the first module to declare its settings** and the model for the rest:
|
||||
its log rotation, keeping containers through a daemon restart, and its resolver are tunables, and
|
||||
its trusted registries are what the mesh tells it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The console can show a module's settings as a form: what can be set, of what type, its default,
|
||||
and where the current value came from. That is the surface the operator wants for changing a
|
||||
default later.
|
||||
- `settings set` can refuse an unknown key, so ADR 0046's rule is enforced where it was only stated.
|
||||
- The resolver's upstreams, the runtime's log rotation, and other literals a definition carries
|
||||
because it could not give them a default become declared tunables.
|
||||
- **What got harder:** every module that takes settings must list them, and a mergeable file no
|
||||
longer silently accepts a key its author did not foresee. A person who needs one adds it to the
|
||||
definition, which is a new module version, not a setting.
|
||||
- Issue 173's open question — a consumer checks nothing against a contract — is unchanged; this record
|
||||
is the operator half of design 27's contract, not the provider half.
|
||||
- Not decided here: the spelling (design 27); whether a node's layer may be narrowed to a single key
|
||||
rather than replaced whole, as `settings set` does today.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Every setting a module takes is declared | A parser test refusing a setting declaration without a type or meaning; a catalogue test listing modules with mergeable files or `${setting:}` and no declarations, which must be empty before the implicit form is removed |
|
||||
| A tunable resolves to its default; an operator value without one is refused | Resolution tests: an unset tunable in a JSON file and in a text file both take the default; an unset setting with no default is refused naming it (0155's existing test) |
|
||||
| A setting reaches only the files it names | A resolution test: a module with two mergeable files and a setting declared for one; the other file's content is unchanged by it (the case of issue 198) |
|
||||
| An undeclared key is refused when set | A controller test: `settings set` with an undeclared key fails naming the declared keys, and nothing is stored |
|
||||
| Every effective value names its source | A test listing a module's configuration on a node with one key from each of default, mesh and node |
|
||||
| A change's reach is shown before it moves | A plan test: changing a mesh-wide setting names every assignment whose effective value moves and no other |
|
||||
| The strongest cost applies | A host test: a file where a reload-cost key and a restart-cost key both moved restarts; a file where only reload-cost keys moved reloads |
|
||||
| Live | The container runtime's module lists its settings with their sources on every machine, and a mesh-wide change to its log rotation reaches all four at the next push |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)
|
||||
- [Design 27 — a module requires, the mesh resolves](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md)
|
||||
- Issues [110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md), [173](../04-ISSUES/173-a-modules-settings-reach-every-fact-it-contributes/00-report.md)
|
||||
- mesh-controller `internal/catalogue/settings.go` (`settle`, `UnusedSettings`), `internal/catalogue/setting_into.go`
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0161-what-deserves-a-seat.md
|
||||
---
|
||||
|
||||
# 165. `container-runtime` is what a machine can run; that a runtime is running is its holder's health
|
||||
|
||||
## Context
|
||||
|
||||
A capability is a requirement a module places on a machine, detected by the host and renewed with
|
||||
every report ([ADR 0161](0161-what-deserves-a-seat.md)). The host's `container-runtime` asks the
|
||||
daemon for its version: *a running daemon, not an installed client*. It was made that way by
|
||||
[issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md), where an
|
||||
installed package was believed to be a working service, and
|
||||
[design 05](../03-DESIGN/01-to-be/05-the-node-host.md)'s table says the same: *a runtime is
|
||||
running*. The installer's preflight borrows the same detector to wait for the runtime the
|
||||
foundation bundle installs, so there is one answer to "is there a runtime here".
|
||||
|
||||
The mesh is now to have a module for the runtime itself — its packages, its configuration, its
|
||||
service — on every machine ([ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)).
|
||||
That module cannot declare `container-runtime` as defined: it would require the very thing it
|
||||
installs, the cycle [research 011](../01-RESEARCH/011-the-module-graph/cases.md)'s case 12 names
|
||||
("something the mesh installs that then becomes a node capability"). The operator defined the word
|
||||
for it: **`container-runtime` means the machine is able, at the kernel level, to install a runtime and
|
||||
execute containers** — not that one is installed, and not that one is running.
|
||||
|
||||
The host already draws this line once. `seat` is hardware, a display server *could* run here;
|
||||
`graphical-session` is state, one *is* running; the detector's own comment says "assignment needs the
|
||||
first". A machine without a display has no seat however much software is installed, and a machine
|
||||
with one has a seat before anything is.
|
||||
|
||||
Fifty-four catalogue modules declare `container-runtime` today, counted on the catalogue's main
|
||||
branch on the day of deciding: every module that delivers a container. Each relies on the current
|
||||
meaning to keep it off a machine with no running runtime.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the meaning; let the runtime's module declare nothing.** Rejected: a module that installs
|
||||
the runtime has requirements on the machine — the kernel features without which installing it is
|
||||
pointless — and would state none of them. The cycle stays, only hidden.
|
||||
2. **Two capabilities, "can run" and "is running".** Rejected: the second is made true by assigning a
|
||||
module, so it is the module's state, not a fact of the machine; a capability the mesh itself
|
||||
flips by its own assignment is case 12's cycle with an extra name.
|
||||
3. **The capability is the kernel's; whether a runtime runs is the runtime module's health, and a
|
||||
module that delivers a container needs the runtime's seat held.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**`container-runtime` is detected from what the kernel offers**, as `seat` is: the namespaces a
|
||||
container needs, a control-group hierarchy the runtime can manage, and an overlay filesystem the
|
||||
running kernel has or can load. Present when all three are; absent naming the missing one. Nothing is
|
||||
run and no runtime is asked. The verdict's detail names what was found, not a runtime's version.
|
||||
|
||||
**"A runtime is running and answers" is one probe, owned by the host and used twice:** by the
|
||||
installer's preflight, which waits for the runtime the foundation installs, and as the runtime
|
||||
module's health. It asks the daemon, as issue 007 requires. The preflight stops borrowing the
|
||||
capability's detector, and there is still one answer to "is a runtime running here".
|
||||
|
||||
**The runtime's module declares `container-runtime`**, with `package-manager`, `service-manager` and
|
||||
`privileged`, like any module that manages machine software.
|
||||
|
||||
**A module that delivers a container needs the runtime seat held on its machine**, and is refused
|
||||
otherwise, naming the seat and the modules that could hold it — the refusal design 27 already lists
|
||||
for an unheld seat. That requirement is derived from the container resource and needs no manifest
|
||||
field ([ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)).
|
||||
The fifty-four existing declarations of the capability stay valid and become redundant; a catalogue
|
||||
test lists them, and they retire when the list is empty.
|
||||
|
||||
**The order is fixed, not preferred.** The detector changes only once the seat requirement is
|
||||
enforced. In between, a machine with the kernel and no running runtime would read as able to run
|
||||
every containerised module, which is issue 007 again.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Design 05's capability table changes its `container-runtime` row from *a runtime is running* to
|
||||
*the kernel can run containers*, and names the runtime module's health as where "running" is now
|
||||
asked.
|
||||
- The node listing stops showing the runtime's version beside the capability. The version moves to
|
||||
the runtime module's health and its seat's verbs.
|
||||
- A fresh machine with no runtime reads as able to run one, so it can be assigned the runtime's
|
||||
module, which is what makes the mesh able to install the runtime instead of the bootstrap alone.
|
||||
- **What got harder:** "is this machine running containers" is no longer one glance at the profile;
|
||||
it is the runtime seat's holder and its health. The node's listing should show both side by side.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The capability is the kernel's | Host detector tests over a fixture `/proc` and `/sys`: all three present → present; each one missing → absent naming it; no runtime binary on the fixture machine changes nothing |
|
||||
| One probe asks whether a runtime runs | A host test that the preflight and the runtime module's health call the same probe, and that the probe fails against a stopped daemon with an installed client (issue 007's shape) |
|
||||
| A containerised module needs the runtime seat held | A resolution test: a module with a container resource on a machine whose runtime seat is unheld is refused, naming the seat and its candidate holders |
|
||||
| The order holds | The host release that changes the detector is gated on the controller release that enforces the seat requirement — stated in both changes' descriptions and checked at review |
|
||||
| Live | Every machine's profile shows `container-runtime` present with the kernel's features as its detail; a machine with no runtime installed reads present too |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0161](0161-what-deserves-a-seat.md) — the profile renewed by every report; a capability that names a dialect
|
||||
- [ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md) — the seat and its holder
|
||||
- [Issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md), [research 011](../01-RESEARCH/011-the-module-graph/cases.md) cases 12–13
|
||||
- [Design 05 — the node host](../03-DESIGN/01-to-be/05-the-node-host.md)
|
||||
- mesh-host `internal/profile/detectors.go` (the runtime detector), `internal/profile/seat.go` (the hardware/state split), `internal/bootstrap/preflight.go` (the preflight that borrows it)
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0161-what-deserves-a-seat.md
|
||||
---
|
||||
|
||||
# 166. The container runtime is a node seat, and the host creates containers through its holder
|
||||
|
||||
## Context
|
||||
|
||||
Every container the mesh runs on a machine is created by the host, which looks for a runtime
|
||||
(`docker info`, then `podman info`) and drives that runtime's command line itself: run, inspect,
|
||||
remove, exec. Research 012 called this "detected rather than declared": the host takes over whatever
|
||||
runtime it finds. Nothing in the mesh owns the runtime. Its package came from the foundation bundle
|
||||
or was already on the machine. Its configuration file was written by hand, differs on each of the
|
||||
four machines, and is also written into by two modules that are not the runtime's
|
||||
([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)).
|
||||
Its service is declared by those same two.
|
||||
|
||||
The operator set the direction:
|
||||
|
||||
- a module for the runtime, on every machine, owning "what is needed to run containers here": its
|
||||
packages, its configuration and its service;
|
||||
- that module holds a node seat for the runtime, so a second runtime (podman) can later compete
|
||||
for the seat;
|
||||
- the host stops speaking to the runtime directly and uses the seat's holder. The host stays the one
|
||||
that decides, and the holder becomes the one that executes;
|
||||
- every container on the machine is in scope, not only the mesh's. A development environment started
|
||||
by hand, or a test database a tool runs, is legitimate. The host already calls these *strays*: 3,
|
||||
8 and 25 on three of the machines on the day of deciding;
|
||||
- the runtime's events and verbs are subjects on the bus, and the mesh's own interface is built on
|
||||
them. The third-party interface run until now was removed by hand.
|
||||
|
||||
[ADR 0161](0161-what-deserves-a-seat.md)'s test for a seat is whether the mesh's own code finds it by
|
||||
name. Here it does: the host would look up the holder on its own machine. A singular role of a module
|
||||
held once per machine is a `node-*` seat ([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)),
|
||||
in the controller's seed.
|
||||
|
||||
The constraint that decides most of this record is a cycle. The bus runs in containers. On the
|
||||
broker's machine, the broker's own container is created by the host. A holder's code served from a
|
||||
container cannot create the container that runs it. On a first machine, before the controller exists,
|
||||
nothing holds anything.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The host calls the holder's verbs over the bus.** Rejected: with the broker down, no machine can
|
||||
create any container, including the broker's. The mesh would be unable to restart its own
|
||||
transport.
|
||||
2. **The holder picks a dialect that the host speaks itself, as with the uplink.** Rejected: the
|
||||
host would still drive the runtime, and the module would drive it too for every other caller.
|
||||
That is two programs speaking to one daemon, and they come to disagree about the same machine
|
||||
(the installer's preflight already exists to avoid this).
|
||||
3. **The holder's code runs as a supervised process on the machine and serves the seat's verbs
|
||||
twice: locally to the host, on the bus to everyone else.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**`node-container-runtime` is a seat of the mesh's own, node-scoped,** in the controller's seed under
|
||||
this record. It delivers no provision; what it carries is its role's protocol: verbs its holder must
|
||||
serve ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)) and events its holder emits
|
||||
([ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md)). The runtime's module, `docker`, claims
|
||||
it and is assigned to every machine. A podman module may claim it later; one machine runs one.
|
||||
|
||||
**The seat's verbs cover every container on the machine:** list, inspect, logs, stats, start, stop,
|
||||
restart, create and remove. A mesh-held container is marked by the host's label and says which
|
||||
assignment holds it. **A container the runtime runs can be root on the machine** — privileged, a host
|
||||
path mounted, the host's network or process namespace, the runtime's own socket — so a caller other
|
||||
than the host may not create one that is any of these; only a declaration the mesh composed may ask
|
||||
for them. And the verbs that change anything are granted by name, never by a wildcard: a grant of
|
||||
every tool (the console's today) reaches the reading verbs only. Issue 193 is what a verb that trusts
|
||||
its caller costs. **Creating or removing a mesh-held container is the host's alone.** Any other
|
||||
caller is refused naming the assignment, because the host would undo it at its next apply. Starting,
|
||||
stopping or restarting one is allowed, and the answer says the host will restore what its
|
||||
declaration says. A container the mesh does not hold is the caller's to do anything with.
|
||||
|
||||
**The seat's events are the runtime's own** — a container created, started, stopped, died, removed,
|
||||
its health changed. They are emitted on the seat's subjects, so every holder emits the same events and
|
||||
no reader depends on which runtime holds the seat. As
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
||||
decides, the subjects are issued by the controller, not composed by the module.
|
||||
|
||||
**The holder's code is a supervised process, not a container** ([ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)).
|
||||
A runtime cannot be run by the thing it runs. The process serves the seat's verbs on the bus to the
|
||||
console, to tools and to the mesh's interface. The same verbs are served on a local socket on the
|
||||
machine, which only the host may use. **The host creates, inspects and removes its containers
|
||||
through that socket and nothing else.** If the holder does not answer, the host creates nothing. It
|
||||
says so in its report, naming the seat. It never falls back to the command line.
|
||||
|
||||
**A container needs the seat held on its machine.** An assignment that delivers a container on a
|
||||
machine whose runtime seat is unheld is refused, naming the seat and its candidates
|
||||
([ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md)).
|
||||
Mounting the runtime's socket into a container is granted by the seat, not by the capability. The
|
||||
socket's path is the holder's to state, because podman's is not docker's.
|
||||
|
||||
**The runtime module owns the runtime's configuration.** Its settings are declared with defaults
|
||||
([ADR 0164](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md)):
|
||||
the resolver containers use, the registries it trusts, log rotation, and keeping containers through a
|
||||
daemon restart. The module is given the resolver's address and the mesh's registry as values; no other
|
||||
module writes the runtime's file or declares its service.
|
||||
|
||||
**The first machine is bootstrapped with the holder, and adopted afterwards.** The foundation bundle
|
||||
already installs the runtime's package and service. It also carries the holder's process, delivered as
|
||||
a binary the way the host is ([ADR 0142](0142-the-mesh-delivers-its-own-components-as-binaries.md)).
|
||||
When the runtime module is assigned, it adopts what the bundle made, as the store and broker modules
|
||||
adopt theirs ([ADR 0078](0078-the-store-and-broker-are-modules.md)).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The migration on the running mesh has a fixed order:**
|
||||
1. Each machine's hand-written configuration is read, because the module's defaults replace what
|
||||
differs.
|
||||
2. In one push per machine: the resolver module and the private network stop writing the
|
||||
runtime's file ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)),
|
||||
and the runtime module is assigned and adopts the runtime, its file and its service. Split in
|
||||
two, either the controller refuses two modules declaring one path, or a machine is left with
|
||||
nothing setting `dns` and `live-restore`.
|
||||
3. The controller seeds the seat and enforces the container requirement.
|
||||
4. The host releases the version that uses the holder.
|
||||
5. The host's command-line path is removed in the release after every machine's holder answers.
|
||||
Until then, the host reports per machine which path it used.
|
||||
- **Every container the host makes depends on the holder's process.** A crash-looping holder stops
|
||||
new containers on its machine. Running containers are unaffected. The host's report names the cause.
|
||||
- The process form of a module's own code must serve tools on the live mesh before this ships. Only the
|
||||
showcase declares it, and [issue 117](../04-ISSUES/117-a-modules-own-code-is-a-container-and-a-process/01-diagnosis.md)
|
||||
found the showcase's tools declared in a form nothing runs. The runtime module is the first whose
|
||||
tools cannot fall back to a container.
|
||||
- A user interface subscribing to events directly does not exist. Today a reader of events is a module
|
||||
that consumes them. The mesh's container view is a module, or waits for that path.
|
||||
- [ADR 0005](0005-the-node-host.md) ("a container runtime is detected, not chosen") and
|
||||
[ADR 0006](0006-the-substrate-and-the-control-plane.md)'s matching line describe the mechanism this replaces: on
|
||||
acceptance, each gets a dated note saying the runtime is now a seat's holder, as the decision
|
||||
records' rule for a moved mechanism requires. Design 05 and design 26 are amended after acceptance.
|
||||
- The operator's decision to remove the third-party interface by hand needs no mechanism. No
|
||||
module-retires-module rule is introduced.
|
||||
- **What got harder:** the host gains a dependency it did not have, and a first machine's bundle gains
|
||||
a component. The direct path was simpler and is what makes a runtime a black box to the rest of the
|
||||
mesh.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The seat is the mesh's own, node-scoped, with its verbs and events | A catalogue test on the default seats; registration refuses a claimant that does not serve every verb (design 33's existing check) |
|
||||
| Creating or removing a mesh-held container is the host's alone | A test of the runtime module's verbs: create or remove of a container carrying the host's label, from any caller but the host's socket, is refused naming the assignment; the same verbs on an unlabelled container succeed |
|
||||
| No caller but the host creates a container that is root on the machine | A test of `create` from the bus: privileged, a host path, the host's namespaces and the runtime's socket are each refused; the same request on the host's socket is accepted. A broker test: a grant of every tool does not reach a changing verb |
|
||||
| The host uses the holder and never the command line | A host test with a fake holder on the local socket: every container operation goes to it, and with the holder absent the apply creates nothing and reports the seat; after step 5, the host carries no command-line runtime code (checked by build: the package is gone) |
|
||||
| A container needs the seat held | A resolution test refusing a containerised assignment on a machine with the seat unheld, naming the seat |
|
||||
| Socket mounts are granted by the seat | A catalogue test: a module mounting the runtime's socket on a machine whose holder states a different path is refused |
|
||||
| No other module writes the runtime's file | The existing collision check, once the private network's computed resources are inside it ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)) |
|
||||
| Live | `seats` lists `node-container-runtime` held on every machine; the node listing shows each machine's containers, strays included, from the seat's `list` verb; a container started by hand appears as an event on the bus |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [ADR 0161](0161-what-deserves-a-seat.md)
|
||||
- [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md), [ADR 0142](0142-the-mesh-delivers-its-own-components-as-binaries.md), [ADR 0078](0078-the-store-and-broker-are-modules.md), [ADR 0005](0005-the-node-host.md)
|
||||
- [ADR 0164](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md), [ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md), [issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)
|
||||
- [Design 26 — the seats](../03-DESIGN/01-to-be/26-the-seats.md), [design 33 — the tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)
|
||||
- mesh-host `internal/apply/apply.go` (the runtime lookup and the command line it drives)
|
||||
@@ -113,6 +113,22 @@ That is a difference a take shows, not a fault, and is decided when it bites.
|
||||
| `node show` lists filters with owners; `status` names a converged machine something else filters and is not well; the preview lists filters and fates | controller tests over a fixture report |
|
||||
| Live | the home server's record names the predecessor's chain in the runtime's user chain as *other*; `status` names the machine; after the operator removes the chain, the next report drops it and `status` is well |
|
||||
|
||||
## Built and proven live, 2026-10-02
|
||||
|
||||
> **Progressive insight — 2026-10-02.** The decision stands; these are the facts of its building.
|
||||
|
||||
Built in mesh-host 67 (every refusing table and legacy chain classified with an owner, reported with
|
||||
every apply; the found firewall retired on every converged apply, *found inactive* kept apart from
|
||||
*disabled by the mesh*, a skipped step said) and mesh-controller 211 (kept per node, shown on `node
|
||||
show`, named by `status` and not well, previewed with fates). The live row was read at 10:10Z: the home
|
||||
server's record named the predecessor's chain in the legacy filter's user chain as *other*, beside two
|
||||
chains a retired front end left in the IPv6 legacy filter; the control node's record named the same two
|
||||
leftovers; the laptop and the workstation read *the mesh alone*; `status` named both machines. The five
|
||||
rule sets were removed at 12:46Z through the packet filter seat's `remove` verb
|
||||
([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)), and the next report read *the mesh alone* on
|
||||
all four machines. The control node's record still says the mesh retired its front end, which issue 143
|
||||
records as a hand's work: the host trusts its record, and from this build on the distinction is kept.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [ADR 0103](0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md), [ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0163](0163-taking-a-module-over-is-a-comparison.md)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0004-a-node-and-how-it-joins.md
|
||||
---
|
||||
|
||||
# 169. A machine joins through the tunnel, and the bus is never public
|
||||
|
||||
## Context
|
||||
|
||||
The bus is the one channel every machine depends on: enrolment, every declaration, every tool. The
|
||||
`nats` module declares it reachable from the mesh only. The controller still opens it to the whole
|
||||
internet on the machine that runs it, as a *foundation* port that no module declares and nothing may
|
||||
close ([issue 051](../04-ISSUES/051-the-mesh-cannot-update-what-it-depends-on/00-report.md)).
|
||||
The reason is joining. [ADR 0004](0004-a-node-and-how-it-joins.md) has a new machine enrol over the bus
|
||||
**before** it has a tunnel. [ADR 0007](0007-connectivity.md) states it as a requirement: the node
|
||||
running the broker must be reachable from wherever nodes are, at a stable address.
|
||||
|
||||
So the bus listens on the internet permanently, for an event that happens a few times a year. A
|
||||
sweep of every machine on 2026-10-02 found no client using the public path. Every connection arrives
|
||||
over the tunnel or from the machine itself. The join token does not use it either: it carries the
|
||||
controller's configured broker address, a mesh name with the old broker's port.
|
||||
|
||||
ADR 0004 already says what a joining machine needs: *an identity, an address, and one peer to reach*.
|
||||
The tunnel can be that peer, if the hub knows the new machine's key before the machine first knocks.
|
||||
WireGuard answers nothing to a key it does not know, which is why the tunnel's own port is safe to
|
||||
leave open where the bus's is not.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the bus public.** It is authenticated and encrypted, but every exposure of it, and of the
|
||||
server behind it, is exposure of the one thing everything depends on.
|
||||
2. **Open the bus publicly only while a join token is live.** Small, and the hub is open only during a
|
||||
join window. But the window is real, the rule is about time rather than about who may reach the
|
||||
bus, and the opening and closing are pushes that can fail between them.
|
||||
3. **The controller makes the new machine's tunnel key and puts it in the token.** One step for the
|
||||
operator, but the private half leaves a machine it does not belong to. ADR 0004 refuses that for
|
||||
every key a node holds.
|
||||
4. **The machine makes its key first, and the token is issued for it.** The machine prints the public
|
||||
half of its tunnel key. The operator issues the token for that key. The controller gives the
|
||||
machine its address and adds it as a peer on the hub. The token carries the hub's tunnel endpoint
|
||||
and key, the machine's address, and the bus's address on the private network. The machine brings
|
||||
up its tunnel and enrols over it.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 4.**
|
||||
|
||||
- **A machine makes its own tunnel key before it has a token**, and prints the public half. The private
|
||||
half never leaves it, as ADR 0004 says of every key a node holds.
|
||||
- **A token is issued for a tunnel key.** Issuing it assigns the machine's address on the private
|
||||
network, records the key, and makes the machine a peer of the hub. The hub is sent that before the
|
||||
token is shown, so the tunnel answers the moment the machine first uses it.
|
||||
- **The token carries the one peer.** It adds the hub's tunnel endpoint and public key and the
|
||||
machine's own address. **Where** becomes the bus's address on the private network, which needs no
|
||||
name resolution.
|
||||
- **The machine joins through the tunnel.** It brings the tunnel up from the token alone, then enrols
|
||||
over it exactly as before. The enrolment checks that the key it is offered is the one the token was
|
||||
issued for.
|
||||
- **The bus is never public.** It is no longer a foundation port. Its reach is what the `nats` module
|
||||
declares: the mesh. The tunnel's port stays open, as the one way in.
|
||||
|
||||
This changes three things earlier records say. ADR 0004's *where* is the bus's private address, and the
|
||||
token carries the peer. ADR 0007's requirement that the broker be reachable from wherever nodes are
|
||||
becomes: **the hub's tunnel is**. Issue 051's broker port stops being a foundation port.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Joining is two commands on the new machine, with the token issued between them. A token issued for
|
||||
the wrong key gives a tunnel that never answers, and the machine says so rather than timing out at
|
||||
the bus.
|
||||
- An unused token leaves a peer on the hub until it expires. Expiry removes it, the same way it voids
|
||||
the secret.
|
||||
- A machine already in the mesh is unaffected: it reaches the bus over its tunnel today.
|
||||
- The genesis machine, the first one, raises the bus on itself and needs no tunnel to reach it.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A token is refused without a tunnel key, and carries the hub's peer and the machine's address | a controller test |
|
||||
| Issuing a token makes the machine a peer of the hub before the token is shown | a controller test over the hub's composed tunnel |
|
||||
| An expired, unused token's peer is gone from the hub | a controller test |
|
||||
| Enrolment refuses a tunnel key other than the one the token was issued for | a controller test |
|
||||
| No machine's filter opens the bus to anywhere | a controller test over the composed filter, and the live sweep from outside the mesh |
|
||||
| A new machine joins from outside the hub's network with the bus closed to it | the lab, then by hand |
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
|
||||
---
|
||||
|
||||
# 170. The firewall seat serves its verbs, and a foreign rule set is removed through one of them
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md) made the mesh say truthfully
|
||||
what filters a converged machine, and left the removal of what it did not write to the operator's
|
||||
hand. The first time that hand was needed — two machines, five rule sets a predecessor and a
|
||||
retired front end had left — there was no mesh way to lend it: the packet filter is a seat
|
||||
([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)), a seat's
|
||||
holder serves its verbs ([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md),
|
||||
[ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)), and the
|
||||
firewall seat declared none. The only remaining path was a shell on the machine, which is the path
|
||||
the mesh exists to replace, and which the operator's own tooling rightly refused to an agent.
|
||||
|
||||
A seat's verbs are the contract every holder implements, whatever filter it speaks. What a person
|
||||
asks a machine's packet filter is the same whether nftables, a front end or a legacy filter answers:
|
||||
what are the rules, reload the mesh's own, remove this thing the mesh did not write. What differs by
|
||||
filter is the holder's own business and may be its own tools beside the seat's.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. The `node-packet-filter` seat serves three verbs**, and a module that claims it serves all
|
||||
three or is refused the claim, as with every seat:
|
||||
|
||||
- `rules` — the packet filter as the machine enforces it now: the nftables ruleset, and the legacy
|
||||
filter's listings where that tool exists; narrowed to one table or chain when asked. Read-only.
|
||||
- `reload` — load the mesh's own filter again from the file the mesh writes, and answer with the
|
||||
mesh's table as loaded. The holder's own act on the holder's own rules.
|
||||
- `remove` — remove one rule set the mesh did not write, named exactly as the host reports it under
|
||||
ADR 0168 (`chain HAL-MESH-ONLY (iptables-legacy)`, `table ip6 filter, chain DOCKER-USER`), and
|
||||
answer with what was done. It refuses the mesh's own tables, the container runtime's own chains,
|
||||
a built-in chain other than the runtime's user chain, and any chain of a found firewall that is
|
||||
in force. The runtime's user chain is emptied back to its one return; another chain loses the
|
||||
jumps into it, is flushed and deleted; a table of the machine's own is deleted whole. Each is an
|
||||
operator's act, by name, on one thing the mesh reported — never a flush, never a rule the mesh
|
||||
itself marked.
|
||||
|
||||
**2. A holder may serve its own tools beside the seat's.** The nftables module keeps its reading of
|
||||
the mesh's table as its own tool, and a holder speaking a filter with specifics of its own may add
|
||||
tools for them; the seat's three are what every holder owes.
|
||||
|
||||
**3. A container may ask for a capability.** Serving `remove` and `reload` needs the machine's
|
||||
network namespace and the right to change its packet filter; a holder's runtime declares
|
||||
`capabilities: ["NET_ADMIN"]` on its container and runs on the machine's network. The host grants
|
||||
exactly the capabilities declared, names them in the container's spec so a change recreates it, and
|
||||
refuses a name that is not a capability's. A privileged container stays undeclarable.
|
||||
|
||||
**4. ADR 0168's "by hand" is read as "by the operator, through the seat".** Removing what the mesh
|
||||
reports as *other* is still the operator's act and is still never the mesh's own doing; the verb is
|
||||
how the act reaches the machine, recorded on the bus like every other, instead of a shell.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The seat's row gains the three verbs; a mesh that already runs widens its row at the next
|
||||
controller start. The nftables module claims them and gains a runtime — a tool server with the
|
||||
packet filter's tools in its image, on the machine's network, with `NET_ADMIN`.
|
||||
- The host's container vocabulary grows by `capabilities`; an older host refuses a declaration that
|
||||
carries it, so the host rolls before the module.
|
||||
- The two machines of this mesh that ADR 0168 found not filtered by the mesh alone are cleaned
|
||||
through `remove`, and read *the mesh alone* afterwards; `status` returns to well without a hand on
|
||||
either machine.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The seat declares the three verbs; a claim that serves fewer is refused by name | the catalogue's seat tests |
|
||||
| `remove` refuses the mesh's tables, the runtime's chains, a built-in chain and an active front end's chains, and removes a user chain with its jumps, empties the user chain, deletes an own table | the module's tests over a fake command runner, with the shapes the host reported live |
|
||||
| A container's capabilities reach the runtime and its spec; an unknown name is refused | host tests |
|
||||
| Live | `node-packet-filter.remove@<node>` on the home server and the control node; `node show` reads *the mesh alone* on both; `status` is well |
|
||||
|
||||
## Built and proven live, 2026-10-02
|
||||
|
||||
> **Progressive insight — 2026-10-02.** The decision stands; these are the facts of its building.
|
||||
> Written as 0169 for three hours and renumbered to 0170: another record took 0169 on main first,
|
||||
> and the check that refuses a shared number covered issues only (now records too).
|
||||
|
||||
Built in mesh-host 68 (`capabilities` on a container), mesh-controller 212 (the seat's three verbs)
|
||||
and 213 (the filter file a module names under `filtering.into` counts as declared for a mount — the
|
||||
module's first build was refused without it), mesh-catalog 216 (the nftables module's runtime and
|
||||
verbs) and mesh-tools 27 (the console lists a node-scoped seat's verbs with their scope and carries the
|
||||
machine; before it, the verbs were live on four machines and unreachable from the console —
|
||||
[issue 199](../04-ISSUES/199-a-node-scoped-seats-verb-could-not-be-called-through-the-console/00-report.md)).
|
||||
Each machine's holder was issued its bus account with `mesh-controller.issue`, the broker node pushed
|
||||
first. At 12:46Z the five rule sets ADR 0168 had named were removed through
|
||||
`node-packet-filter.remove`, three on the home server and two on the control node, each answering
|
||||
with the commands it ran; the next report read *the mesh alone* on all four machines and `status`
|
||||
listed nothing under `filtered`. The live row is read. What it cost on the way is
|
||||
[issue 200](../04-ISSUES/200-the-controllers-answer-to-the-console-is-refused-by-the-bus/00-report.md).
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)
|
||||
- [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md), [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md)
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0016-the-lab.md
|
||||
---
|
||||
|
||||
# 172. The lab is a module, and runs a bed when the mesh asks
|
||||
|
||||
## Context
|
||||
|
||||
The lab raises virtual machines and runs the mesh on them, end to end, before a change reaches a real
|
||||
machine ([ADR 0016](0016-the-lab.md)). It runs on one machine of the mesh, the one with the
|
||||
virtualisation it needs. Until now the only way to start a bed there was to sign in to that machine and
|
||||
run the lab's command line by hand, with a dozen environment variables pointing at sibling checkouts.
|
||||
|
||||
Nothing in the mesh could ask for it. An agent working through the mesh's own tools could build,
|
||||
merge and push a change, and could not prove it in the lab first. The operator's direction on
|
||||
2026-10-02: work on another machine goes through a mesh tool, not a shell on it.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the lab a command line on one machine.** Every run is a person, or an agent with a shell on
|
||||
that machine, outside the mesh.
|
||||
2. **The lab is a module.** Assigned to the machine that can run it, serving tools that run a bed
|
||||
against named branches and say how it went.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 2.**
|
||||
|
||||
- **A `lab` module, assigned where the lab can run**, serves five tools: whether this machine can run
|
||||
beds, run beds against a branch per repository, a run's state, its log, and stopping it.
|
||||
- **A run is the lab's own suite**, against fresh checkouts of the named branches from the mesh's forge,
|
||||
side by side as the lab expects them. It builds what the beds place from those checkouts, as the suite
|
||||
already does. It answers at once with an id, like a build: a bed takes minutes, and a call does not.
|
||||
- **Only branches on the forge are run**, never code handed to the tool. What a run tested is what the
|
||||
forge holds at the commit it names.
|
||||
- **The lab is reached over the mesh only.** Its tools travel the bus, and the module opens no port.
|
||||
- **No grant beyond the mesh's own.** Running a bed is root on the lab's machine, but anyone who can call
|
||||
the mesh's tools can already do worse. The operator's judgement on 2026-10-02.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An agent proves a change in the lab through the mesh, the same way it builds and pushes one.
|
||||
- The lab's machine carries a module whose runtime holds the virtualisation's and the container
|
||||
runtime's sockets, and a toolchain to build the mesh with.
|
||||
- A run's checkouts are its own, so two runs never build from each other's tree. Old ones are removed
|
||||
when their run ends.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A run checks out exactly the named branches, and reports the commits it tested | the module's tests over a forge fixture, and each run's answer |
|
||||
| A run answers at once, and its state and log follow it to the end | by hand, the first run |
|
||||
| The module opens no port | the composed filter of the lab's machine |
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0040-what-a-module-is.md
|
||||
---
|
||||
|
||||
# 173. The operator's machine is the mesh's, and a module is whatever it declares
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0040](0040-what-a-module-is.md) says a module is *one self-contained piece of software the
|
||||
mesh installs and manages*, and every example it gives is a service: a database, an analytics
|
||||
server, a forge. The catalogue followed the examples. Of the predecessor's 34 modules on one
|
||||
workstation, 28 are the operator's environment — a login manager, a window manager with 88 files
|
||||
and four flavors, a shell, a terminal, a launcher, an audio setup, scripts — and the migration
|
||||
scoped all 28 out as *the workstation's own environment*, to be managed by nobody
|
||||
([research 018](../01-RESEARCH/018-the-operators-machine-as-modules/02-what-exists-and-what-is-missing.md)).
|
||||
Since the predecessor retired, nobody is exactly who manages them: a fix is a hand edit that
|
||||
nothing records and nothing regenerates.
|
||||
|
||||
[To-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) reached under the home for
|
||||
one directory and drew a boundary inside it. The operator's statement is wider: *the mesh manages
|
||||
my entire machine, all four of them, as far as it makes sense* — system folders and the home
|
||||
alike, the servers and the workstations from the same catalogue. And the operator refused a
|
||||
distinction this effort first drew between modules that ship code and modules that ship only
|
||||
declarations: *a module can have some tools, a seat implementation, some containers, a unit, a
|
||||
binary, some config files — one of these, or all, or two.*
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep 0040's reading and manage the environment outside the catalogue** — dotfiles in a
|
||||
repository, a script that places them. Rejected: that is the predecessor's first two days, the
|
||||
origin of every inherited shape [as-is 10](../03-DESIGN/00-as-is/10-module-catalogue.md)
|
||||
documents, and it puts the one thing a person looks at outside the one mechanism that is
|
||||
checked.
|
||||
2. **Add a second kind of module for configuration** — a "config module" with files and no
|
||||
process. Rejected by the operator: a kind is a distinction the manifest already makes by what
|
||||
it declares, and a second kind is a second set of rules to keep in step.
|
||||
3. **One definition: a module is one managed thing, described by what it declares.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Everything configurable on a node is declared by a module.** Services, and equally the login
|
||||
manager, the display server, the window manager, the shell, the terminal, the launcher, the
|
||||
notifier, the audio setup, the boot images, the package manager's configuration, the agent at the
|
||||
terminal, and a folder a person works in. The test is *can it be configured on a machine*; if it
|
||||
can, some module owns it. What no module declares is found and left alone, as adoption already
|
||||
says of a machine ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)).
|
||||
|
||||
**2. A module is whatever it declares, and there are no kinds of module.** A package, files, a
|
||||
container, a unit, a binary, a seat claim, tools — any one, or all. 0040's *one self-contained piece
|
||||
of software* stands; its examples were services, and that was the whole of the bias. A downloads
|
||||
folder with a process that tidies it, backs it up and answers questions about it is a piece of
|
||||
software by 0040's own test, and so is a shell that is a package, three files and a seat.
|
||||
|
||||
**3. The home has no boundary of its own.** A file under the operator's home is placed and owned
|
||||
the way [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §2 built it: by a
|
||||
module, resolved against the account's home, owned by the account. Which files are the mesh's is
|
||||
decided by what modules declare, not by a line drawn through a directory. A person's documents,
|
||||
projects and history are data under [ADR 0051](0051-shared-data-is-the-operators.md) and no module
|
||||
declares them.
|
||||
|
||||
**4. One module ships one default configuration.** No flavors. What differed between the
|
||||
predecessor's four flavors of one desktop module is what [ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
|
||||
is for.
|
||||
|
||||
**5. Servers and workstations take the same catalogue.** A module declares what it needs; a
|
||||
machine reports what it has; assignment refuses by name
|
||||
([ADR 0161](0161-what-deserves-a-seat.md) §3). The shell, the prompt, git and the agent are universal.
|
||||
A display server needs a graphical session; a window manager needs the display server held. Nothing
|
||||
in a manifest says *workstation*.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalogue grows by a family of modules that run no service. Each is still built,
|
||||
registered, assigned, pushed and reported like every other, and `status` says whether a
|
||||
machine has applied them.
|
||||
- The account fact becomes load-bearing for every node a person uses. Today it is empty on all
|
||||
four node records of this mesh; stating it is the first step of the build.
|
||||
- A module that *installs* a thing is distinct from a module that *holds its role*: zsh, fish and
|
||||
bash may all be installed, and one holds the login shell
|
||||
([ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)).
|
||||
- The host's `package` shape drives the distribution's package manager only. A module whose
|
||||
package is outside the distribution's repositories — the login manager in use is one — needs
|
||||
either an official package or a shape the host does not have. Recorded as a gap, not decided.
|
||||
- The predecessor's hooks go. What they did becomes declared state the host applies, or a verb a
|
||||
seat serves ([ADR 0177](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)).
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A manifest with no container, no unit and no binary registers and resolves like any other | the catalogue's registration tests, with a package-and-files manifest |
|
||||
| A file resource under the home resolves against the account and is owned by it | the controller's composition tests (to-be 29 §2, built) |
|
||||
| A home-scoped module is refused on a node with no account, naming the fact | the same tests |
|
||||
| A module needing a capability the machine lacks is refused by name | the resolver's tests (ADR 0161 §3) |
|
||||
|
||||
## References
|
||||
|
||||
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/00-overview.md), documents
|
||||
01 and 02 — the behaviour wanted and the inventory measured.
|
||||
- [ADR 0040](0040-what-a-module-is.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md),
|
||||
[ADR 0051](0051-shared-data-is-the-operators.md), [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)
|
||||
- [To-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the account and the home
|
||||
as a placement root, built; the records for them are proposed in an open change.
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
---
|
||||
topic: building it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
|
||||
---
|
||||
|
||||
# 174. A node varies a module through settings and kept regions, never through an edit
|
||||
|
||||
> **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
|
||||
edit to it is overwritten without warning. The predecessor said the same and then undid it twice:
|
||||
a `merge` strategy that adopted disk drift back into its database, so a local edit became the
|
||||
record; and a theming layer of about 90 environment variables substituted into templates at sync
|
||||
time, with tools to list and set them, so that *nearly every value was a variable* — a second
|
||||
configuration language laid over the first.
|
||||
|
||||
The operator wants both the variation and the rule. One window-manager module with one default
|
||||
configuration, and each node tweaking it; and the file carrying the wanted value rather than a
|
||||
variable the file reads. Two mechanisms already exist for exactly this: a **setting**, declared by
|
||||
the module and set per mesh or per node, rendered at composition
|
||||
(`${setting:…}` is live in the resolver's manifest); and a **kept region**, a block in a file the
|
||||
mesh writes *into* where the operator's own lines survive every push
|
||||
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), used by the ssh-client module
|
||||
for the operator's own `Host` blocks).
|
||||
|
||||
What stands in the way is [issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md):
|
||||
a setting today reaches every mergeable file and every contribution of its module. Ninety theme
|
||||
knobs on that mechanism would reach ninety files. The record that fixes it — a setting declared
|
||||
with its type, meaning, default and the file it lands in — is proposed in an open change alongside
|
||||
the container-runtime records.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Carry the predecessor's merge strategy.** A local edit is adopted into the node's layer.
|
||||
Rejected: two writers and no arbiter, which is the option 0011 removed, and the reason a
|
||||
`/model` choice was silently reverted on every node for weeks before anyone found the cause.
|
||||
2. **Carry the environment-variable theming.** Rejected by the operator: the value belongs in
|
||||
the file; a variable the file reads is a second place for the same fact.
|
||||
3. **A per-node file override** — a whole file replaced for one node. Rejected: it is a flavor
|
||||
under another name, and a module update then misses that node entirely.
|
||||
4. **Settings rendered into the file, and kept regions, and nothing else.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A node varies a module in exactly two ways.**
|
||||
|
||||
- **A setting.** Declared by the module with a default, set for the mesh or for one node, rendered
|
||||
into the file at composition. The value is in the file. Asked, the mesh lists every setting
|
||||
with its effective value and where it came from.
|
||||
- **A kept region.** A marked block in a file the mesh writes into, in which the operator's own
|
||||
lines are kept across every push and given back when the module goes (ADR 0102).
|
||||
|
||||
**An edit outside a kept region is overwritten, as ADR 0011 says, and never adopted.** Nothing
|
||||
reads a managed file back into the record.
|
||||
|
||||
**The predecessor's theme knobs become settings** of the modules whose files they render — the
|
||||
window manager's colours are the window manager's settings, the bar's are the bar's — each
|
||||
landing in the file that reads it and no other.
|
||||
|
||||
**Issue 168 is fixed before any environment module declares a setting.** A setting must name the
|
||||
file it lands in; until that ships, the environment modules carry their defaults in their files
|
||||
and no settings.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No flavors, no per-node file copies, no environment layer. A module's definition is one set of
|
||||
files; a node's difference is data in its layer, visible by asking.
|
||||
- The settings record proposed alongside the container-runtime records is on the critical path
|
||||
of every module with a knob, and this record depends on it shipping as proposed.
|
||||
- A kept region is the only place a person edits a managed file, and the file says where it is.
|
||||
The operator's own prompt customisations, aliases and window rules live there.
|
||||
- What got harder: a change that is neither a setting the module declared nor the operator's own
|
||||
lines has no home, and is refused by the mechanism rather than silently kept. That is the point.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A setting reaches only the file its declaration names | the controller's settings tests, once the proposed record ships; issue 168 closes on it |
|
||||
| A kept region survives a push with its content and is given back on undeclare | the host's write-into tests (ADR 0102), with a region declared by an environment module |
|
||||
| An edit outside a region does not survive a push | the same tests, asserting the file equals the composed content outside the region |
|
||||
| Every effective value names its source | `mesh-controller.settings` and the module's own `show-config` tool |
|
||||
|
||||
## References
|
||||
|
||||
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/01-the-intended-behaviour.md) §"One default, varied by settings, never by edits"
|
||||
- [ADR 0011](0011-managed-files-are-generated-never-edited.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md),
|
||||
[issue 168](../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
|
||||
---
|
||||
|
||||
# 175. One tool runtime per node serves every module's tools, on the host side
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** Everything decided here stands: one runtime per node, host-side, every module's tools and every held seat's verbs on the memberships' subjects, root the module's concern, any node calls any tool, the console its serving mode. What moved is how the runtime brings a bundle to life. Decision 3 and the consequence *the node tools runtime needs an interpreter on the machine* read as though a bundle were always interpreted code the runtime imports; a tools bundle is now a process in any language that speaks MCP over stdio to the runtime, and importing a TypeScript bundle is the shortcut, not the contract.
|
||||
|
||||
## Context
|
||||
|
||||
A module's tools are code the module wrote, one function behind each verb, served on the subjects
|
||||
the controller issues in the module's membership
|
||||
([ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)).
|
||||
What *runs* that code is [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md):
|
||||
a supervised process per module under the module's own account, and in the catalogue as built,
|
||||
that process is a container per module per node, built on the tool runtime's base image.
|
||||
|
||||
Measured on the live mesh ([research 018](../01-RESEARCH/018-the-operators-machine-as-modules/03-one-tool-executor-per-node.md)):
|
||||
67 module tools, each served from its module's container; the packet-filter seat's three verbs
|
||||
served by a container with `NET_ADMIN` on every one of four machines, for a module that is
|
||||
otherwise a package, three files and a service; and the console, a container per node, calling
|
||||
everything and serving nothing. The operator's environment adds a dozen modules of the
|
||||
packet-filter shape, and the operator's judgement is plain: *I would never run MCP tools inside
|
||||
a container; that is a very bad design.* And: *I don't care about permissions or account per
|
||||
module, that just complicates things for no good reason. Just a node-level tool executor. If a
|
||||
command needs root, that's the module's concern.*
|
||||
|
||||
The tool runtime itself was written for this. Its own description: *the per-node process that
|
||||
makes a module's tools actually serve — imports the assigned modules' compiled tool entrypoints,
|
||||
each of which registers its tools as it loads; on a node the host resolves the list and starts it
|
||||
like any other supervised workload.* What the catalogue did instead was build one image per module
|
||||
around it.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep a process per module.** Rejected: one container per module per node for software that
|
||||
is not a container, and the account-per-module invariant it exists to protect is one the
|
||||
operator declines to pay for.
|
||||
2. **The host executes tools itself.** Rejected: the host is a static Go binary that loads no
|
||||
plugins; a module's tools are TypeScript on the SDK, and building a second SDK in Go for the
|
||||
host's sake is the cost ADR 0039 refuses.
|
||||
3. **One tool runtime per node, a sibling of the host, loading every assigned module's bundle.**
|
||||
Chosen. It is what the runtime was written to be.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. One tool runtime per node, supervised by the host, on the host side — never a container.**
|
||||
The host starts it the way the launcher starts the host
|
||||
([ADR 0005](0005-the-node-host.md)): a process on the machine, restarted when it dies. It holds one
|
||||
bus credential, the node's. It is module-agnostic: it knows bundles and subjects, nothing of what
|
||||
any module does.
|
||||
|
||||
**2. It serves every assigned module's tools and every held seat's verbs** on the subjects the
|
||||
memberships issue. ADR 0159 and ADR 0160 are unchanged in what they say about subjects, grants
|
||||
and memberships; what changes is that one process on the node subscribes to all of them instead of
|
||||
one process per module. A module that runs a long-lived service of its own — a daemon, a
|
||||
container — keeps it; this record is about tools.
|
||||
|
||||
**3. A module brings its tools as a bundle**, the artifact kind the catalogue already has for
|
||||
interpreted code, built by the pipeline and delivered to the node by the host as it delivers any
|
||||
artifact. Never an image. The runtime loads each bundle as the membership names it, and a push
|
||||
that adds or replaces a bundle reaches a running runtime as a reload.
|
||||
|
||||
**4. Root is the module's concern.** A tool that must change the packet filter or rebuild boot
|
||||
images escalates itself. The runtime does not run as root for everyone's sake; the caller does not
|
||||
know and need not.
|
||||
|
||||
**5. Any node may call any tool on any node.** The runtime's credential may call everything, as
|
||||
the console's already may. A per-module calling grant is not kept.
|
||||
|
||||
**6. The console is this runtime's serving mode, renamed.** [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md)
|
||||
stands in substance — a module assigned per node, MCP on the machine's loopback, the machine's
|
||||
login is the authority — and changes in form: host-side, serving as well as calling, and named for
|
||||
what it is: **node tools**. The mesh's own verbs stay with the controller
|
||||
([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)); a mesh-scoped seat's verbs
|
||||
run on the node that holds it ([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)).
|
||||
|
||||
**Where ADR 0047 and ADR 0150 say a module's tools are served by the module's own process under
|
||||
the module's own account, read this record.** Everything else they decided stands: a tool is served
|
||||
on its own subject, only the module that serves it answers, a module's long-lived processes are the
|
||||
machine's to supervise. The invariant 0150 kept — one account per module — no longer holds for
|
||||
tools, and the reason is stated above: every tool is callable from everywhere by decision 5, so the
|
||||
account no longer scopes anything a caller cannot already reach.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The packet-filter module's container goes; its verbs run on the host side and escalate as they
|
||||
need. [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md) §3's container capability is moot
|
||||
for it.
|
||||
- The tool runtime's base image stays the way a module's *service* may be built; it is no longer
|
||||
the way tools reach a node.
|
||||
- The node tools runtime needs an interpreter on the machine. The module that is the runtime
|
||||
declares it as a package.
|
||||
- The container-runtime seat proposed in an open change says its holder *runs as a supervised
|
||||
process and serves the verbs locally to the host and on the bus*. A supervised process serving
|
||||
verbs is what this runtime is; whether that holder keeps a process of its own or serves through
|
||||
the runtime is for that record's build to say.
|
||||
- What got harder: one process carries every module's tool code on a node, so one module's
|
||||
faulty bundle can take down the node's tools. The runtime loads each bundle guarded and names
|
||||
the one that failed; the others serve.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The runtime loads every bundle its memberships name and serves each tool on its subject | the runtime's tests against a real bus: two bundles, three tools, each answers |
|
||||
| A bundle that fails to load is named and the others serve | the same tests, with one bundle that throws on load |
|
||||
| The host supervises the runtime and restarts it | the host's tests over the launcher's shape |
|
||||
| A push that replaces a bundle reloads it without a restart | the runtime's tests: a bundle replaced on disk, the membership re-read, the new tool answers |
|
||||
| No module in the catalogue declares a container whose only purpose is tools | a catalogue check: a manifest with `tools` and an image artifact built on the tool runtime's base is refused once the runtime is live |
|
||||
| Live | `login-shell.execute@<node>` answers on every node from the node tools runtime; `docker ps` shows no per-module tool container |
|
||||
|
||||
## References
|
||||
|
||||
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/03-one-tool-executor-per-node.md)
|
||||
- [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md), [ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md),
|
||||
[ADR 0152](0152-the-operators-surface-is-a-module-the-console.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md),
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)
|
||||
- [To-be 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md), [to-be 34](../03-DESIGN/01-to-be/34-the-console.md)
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
|
||||
---
|
||||
|
||||
# 176. The login shell is a node seat held by one shell module, and `execute` is its contract
|
||||
|
||||
> **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
|
||||
and fish all join `shell`, and one may be default. The operator's reading is sharper, and it
|
||||
matches [ADR 0126](0126-a-module-declares-its-own-seats.md) better: *installing* a shell is
|
||||
installing software, and several may be installed; *holding* the seat is being the login shell,
|
||||
which a node has exactly one of. A definition says which seats a module can hold; the assignment
|
||||
says which it does.
|
||||
|
||||
A seat carries the tools its holder must serve ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)),
|
||||
and [to-be 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) leaves which verbs each
|
||||
seat serves as a decision per seat, taken slowly. This is the first seat of the operator's
|
||||
environment, and the one every node has.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **A shared `shell` seat with a default**, as 0040's example reads. Rejected: *default* is a
|
||||
second concept beside *holder* for the same fact, and the `user` shape already makes the
|
||||
login shell declared state ([to-be 05](../03-DESIGN/01-to-be/05-the-node-host.md)).
|
||||
2. **No seat; each shell module sets the login shell for itself.** Rejected: two assigned shell
|
||||
modules would fight over `chsh`, and nothing would say which won.
|
||||
3. **An exclusive node-scoped seat, `login-shell`, declared by the shell modules, held by one
|
||||
per node.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. `login-shell` is a node-scoped seat declared by the shell modules.** zsh, fish and bash each
|
||||
declare that they can hold it; a node's assignment says which does; the controller refuses a
|
||||
second holder by name as for every seat. A shell module that is assigned without holding the seat
|
||||
is installed and nothing more.
|
||||
|
||||
**2. Holding the seat sets the account's login shell.** The holder's declaration carries the
|
||||
`user` shape with the shell it provides, so the login shell is declared state the host applies and
|
||||
gives back when the holding moves — `chsh` stops being a hook.
|
||||
|
||||
**3. The seat's contract is `execute`.** One verb, one argument, the command, run on the node the
|
||||
seat is scoped to as the operator account, answering with what it printed and how it exited.
|
||||
Every holder serves it; a holder may serve its own tools beside it
|
||||
([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md) §2) — show the rendered configuration, list
|
||||
the plugins, set a prompt value.
|
||||
|
||||
**4. Any node may call it on any node.** The grant is the node tools runtime's
|
||||
([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) §5):
|
||||
*run `uptime` on every node* is five calls to one verb.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The first environment module is a shell: a package, files under the home owned by the account,
|
||||
a seat declaration and claim, a `user` shape, and one tool. It proves the whole pattern on every
|
||||
node, servers included, before anything graphical is written.
|
||||
- ADR 0040's shell example is read as *installed is not holding*; a dated note in that record says
|
||||
so. Its decision is untouched.
|
||||
- `execute` is a shell on every machine, addressed over the bus. That is the point, and it is
|
||||
the widest verb the mesh serves; it exists because the operator decided every node may call
|
||||
every tool, and this record does not narrow that.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Two shell modules assigned to one node, one holding: one `user` shape in the declaration, naming the holder's shell | the controller's composition tests |
|
||||
| A second claimant is refused by name | the catalogue's seat tests |
|
||||
| `execute` runs as the account and answers output and exit status | the module's tool tests over a fake runner, and live on every node |
|
||||
| The seat's verb appears with its scope and machine in the node tools listing | the runtime's tests (to-be 33 §4) |
|
||||
|
||||
## References
|
||||
|
||||
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md)
|
||||
- [ADR 0040](0040-what-a-module-is.md), [ADR 0126](0126-a-module-declares-its-own-seats.md),
|
||||
[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
|
||||
---
|
||||
|
||||
# 177. A unit may be user-scoped, and the service manager is a node seat whose holder answers for the units
|
||||
|
||||
## Context
|
||||
|
||||
The host's `service` shape puts a system unit into a state. It has no user scope.
|
||||
[To-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) states the gap: *a
|
||||
workstation's per-user daemons have no form the mesh can send.* Four of the predecessor's
|
||||
environment modules ship user units — the desktop's reload watcher and bar watchdog, the audio
|
||||
module's masks, the power module's memory guard, the thermal daemon's profile switcher — and the
|
||||
predecessor needed a hook to enable them because *shipping a unit file does not run it*; one unit
|
||||
was deployed for months and ran on one machine only.
|
||||
|
||||
[ADR 0040](0040-what-a-module-is.md) says the host hardcodes no supervisor, and a swappable
|
||||
machine mechanism is a module implementing a capability — which is what the nftables module is for
|
||||
the packet filter ([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)). The service manager is
|
||||
reported today as a capability, `service-manager`, and held by nobody. The operator's proposal: a
|
||||
systemd module that holds the seat and serves the tools about units, system and user.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep user units as a module concern** — each module runs `systemctl --user` in a hook.
|
||||
Rejected: that is the hook that silently never ran, and an action over the link is refused.
|
||||
2. **The service-manager module applies units** on behalf of others, as a provision. Rejected by
|
||||
the operator: provisioning is for resources a provider creates for a consumer; a unit is
|
||||
declared state the host applies, as every resource is.
|
||||
3. **The host's `service` shape gains a user scope; a systemd module holds the service-manager
|
||||
seat and serves the verbs about units.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. The `service` shape gains `scope`: `system` (the default) or `user`.** A user-scoped unit
|
||||
is applied as the operator account through the account's own service manager: enabled, started,
|
||||
stopped, reloaded on its triggers, exactly as a system unit is, and refused on a node with no
|
||||
account, naming the fact. The host applies it; no module does.
|
||||
|
||||
**2. `node-service-manager` is a seat of the mesh's own, node-scoped**, seeded by the controller
|
||||
under this record, as ADR 0121 requires of a `node-*` name. The `systemd` module claims it and is
|
||||
assigned to every machine whose profile reports `service-manager`.
|
||||
|
||||
**3. The seat's verbs answer for every unit on the machine**, each taking an optional `scope`:
|
||||
`units`, `status`, `start`, `stop`, `restart`, `enable`, `disable`, `journal`. The host applies what
|
||||
is declared; the holder answers questions and operator acts about it, and says, for a mesh-held
|
||||
unit, that the host will restore what its declaration says.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The host's vocabulary grows by one field on one shape, asserted by its count test
|
||||
([to-be 05](../03-DESIGN/01-to-be/05-the-node-host.md)); an older host refuses a declaration
|
||||
carrying it, so the host rolls before the first module that uses it.
|
||||
- The predecessor's four user-unit modules become declarable without a hook.
|
||||
- The seat's holder is the first system seat held by a module that runs nothing of its own: its
|
||||
verbs are served by the node tools runtime ([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
|
||||
- What got harder: `journal` and `status` on a user unit need the account's manager reachable
|
||||
from the runtime's process, which runs as the node's account; the holder's tool escalates or
|
||||
switches user as it needs, which is ADR 0175 §4 applied.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A `service` with `scope: user` is enabled and started under the account, and refused with no account | the host's tests with a fake service manager |
|
||||
| The seat declares its verbs; a claim serving fewer is refused by name | the catalogue's seat tests |
|
||||
| The verbs act on a named unit in the named scope and name the unit's holder when the mesh declares it | the module's tests over a fake runner |
|
||||
| Live | the desktop's reload watcher declared `scope: user` on a workstation; `node-service-manager.status@<node>` reports it active |
|
||||
|
||||
## References
|
||||
|
||||
- [Research 018](../01-RESEARCH/018-the-operators-machine-as-modules/04-the-seats-of-the-environment.md)
|
||||
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md),
|
||||
[ADR 0040](0040-what-a-module-is.md), [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)
|
||||
- [To-be 05](../03-DESIGN/01-to-be/05-the-node-host.md), [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md
|
||||
---
|
||||
|
||||
# 179. The intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail
|
||||
|
||||
## Context
|
||||
|
||||
Read on the control node on 2026-10-02, the day the machines were confirmed filtered by the mesh
|
||||
alone ([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)): the intrusion
|
||||
prevention watched one door. Its two jails read the ssh daemon's journal and its own log, banned
|
||||
five failures in ten minutes for ten minutes, and in a day had seen twelve thousand failed logins
|
||||
from three hundred addresses and banned none of the busiest, which paced themselves at one try every
|
||||
ten minutes. The mail submission port took a hundred and sixty password guesses in the same day from
|
||||
thirty-eight addresses with no jail reading it at all; the forge and the public proxy had no jail
|
||||
either, and the proxy logged nothing a jail could read. Nobody could see the jails without a shell:
|
||||
the module's three tools existed in code and were served by nothing, and the seat it holds declared
|
||||
no verbs.
|
||||
|
||||
Three things were missing and they are three shapes the mesh already has. The packet filter's seat
|
||||
serves verbs every holder owes ([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)); the
|
||||
intrusion seat serves none. A module's `listens` compose into the machine's filter, and [to-be 31](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
|
||||
says a module's `jails` compose into the machine's intrusion prevention the same way — the controller
|
||||
composes them, and no module declares one. And a jail reads a log; a container's output goes to a
|
||||
file of the runtime's own under a path that changes when the container is recreated, which is why
|
||||
no jail could read the mail front end, the forge or the proxy, however they logged.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. The `node-intrusion-prevention` seat serves four verbs**, and a module that claims it serves
|
||||
all four or is refused the claim, as with every seat:
|
||||
|
||||
- `status` — every jail with what it watches, how many addresses it is counting failures against and
|
||||
holding now, and the totals since it started; one jail's detail when named. Read-only.
|
||||
- `banned` — every address banned now, with the jail holding it, when it was banned and when the ban
|
||||
ends. Read-only.
|
||||
- `ban` — ban one address in one jail now, for that jail's ban time. An operator's act on the live
|
||||
ban list, which the mesh composes the rules for and never writes itself.
|
||||
- `unban` — let one address go, from one jail or from every jail.
|
||||
|
||||
A holder may serve its own tools beside these; the fail2ban module reads one jail's effective
|
||||
settings as its own.
|
||||
|
||||
**2. A container may log to the journal.** `logging: journald` on a container has the host run it
|
||||
with the journal as its log driver; the journal keeps the container's name on every line, and
|
||||
`docker logs` keeps working. Where a container logs is part of its spec, so moving it recreates the
|
||||
container, and the only place besides the runtime's own file is the journal: a machine's intrusion
|
||||
prevention reads the journal already, for the ssh daemon, and a container that logs there is read
|
||||
the same way, by the container's name, whatever the container is called by the runtime this time.
|
||||
|
||||
**3. A module with a door declares its jail, and the holder composes them.** What to-be 31 designed
|
||||
is now the rule: a module whose service authenticates from outside — the mail front end, the forge,
|
||||
the public proxy — declares in its manifest what a failed attempt looks like in its log and how to
|
||||
ban on it, naming no node and no path; the module that holds the intrusion seat declares where the
|
||||
composed jails and filters land, and the mesh writes them on every machine that runs both. A machine
|
||||
not running the module has no such jail. The holder restarts its daemon on the composed file.
|
||||
|
||||
**4. The base is strict, and the mesh's own range is never banned.** Three failures in a day ban for
|
||||
a day, on every jail unless the jail says otherwise; banned twice in two weeks, by any jail, is
|
||||
banned for four. The attackers this mesh sees pace themselves under any ten-minute window; a day's
|
||||
window counts them. A person who mistypes three times from one address is out for a day from that
|
||||
address, and never from a machine of the mesh, whose range stays in the never-banned list the module
|
||||
has carried since [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md). The operator
|
||||
chose this knowing it.
|
||||
|
||||
**5. The proxy says a refused name in its log.** A request for a name this mesh does not serve, from
|
||||
outside, is what a scanner does; the proxy already logged a certificate refused for such a name, and
|
||||
now logs the plain request too, with the asking address last, as its own jail's filter expects it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The seat's row gains four verbs; a mesh that already runs widens its row at the next controller
|
||||
start. The fail2ban module claims them and gains a runtime — a tool server whose image carries the
|
||||
fail2ban client, with the daemon's socket shared in from the machine, and nothing else of the
|
||||
machine. The daemon stays the machine's; what runs in the container is only the client.
|
||||
- **That runtime is the shape the catalogue has today, and it is on its way out.**
|
||||
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
|
||||
accepted the same day as this record, replaces a tool container per module with one tool runtime
|
||||
per node on the host side, taking each module's tools as a bundle. Nothing here depends on the
|
||||
container: the verbs, the client that speaks to the daemon over its socket, and the jails are the
|
||||
same code under either. This module converts with the packet filter's, whose runtime that record
|
||||
names, and the socket it needs becomes the node runtime's to reach rather than a mount of its own.
|
||||
- The host's container vocabulary grows by `logging`; an older host refuses a declaration that carries
|
||||
it, so the host rolls before the modules. Three containers are recreated once, when their modules
|
||||
are pushed with the field: the mail front end, the forge and the proxy — each a moment's outage.
|
||||
- The fail2ban module declares where jails compose (`jailing`) and the directory the filters go in;
|
||||
the mail, forge and proxy modules each declare one jail reading the journal by their container's
|
||||
name. The composed jail file is the one resource the daemon restarts on when a module arrives or
|
||||
leaves a machine.
|
||||
- The two base jails and the composed ones take the day's window; the ssh jail's ten minutes are
|
||||
gone. An address banned on the first day of this record stays banned for the day.
|
||||
- The module's three old tools, served by nothing, are replaced by the seat's four verbs and one
|
||||
own tool; `fail2ban_status` as a name is gone.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The seat declares the four verbs; a claim that serves fewer is refused by name | the catalogue's seat tests |
|
||||
| `status`, `banned`, `ban` and `unban` read and steer the daemon through its client, with the shapes fail2ban 1.1.0 printed live; a non-address and a non-name are refused before anything runs | the module's tests over a fake command runner |
|
||||
| A container's `logging` reaches the runtime's arguments and its spec; a place other than the journal is refused | host tests |
|
||||
| A module's jails compose into the holder's file and a filter per jail, and the file is written empty when none is declared | the controller's composition tests (to-be 31) |
|
||||
| The proxy logs a refused name with the address last | the proxy's tests |
|
||||
| A jail's pattern names `<HOST>` once per shape, since two is a duplicate capture group and costs the machine every ban | the catalogue's manifest tests |
|
||||
| Live | done 2026-10-02: `status` and `banned` answered on both servers through the console; the proxy's jail counted seven refusals on the home server; a documentation address banned in the ssh jail came back with its end time and was released |
|
||||
|
||||
## Built and proven live, 2026-10-02
|
||||
|
||||
All five rules are in the mesh. The host carries `logging`; the controller's seat row carries the four
|
||||
verbs and the proxy says a refused name in its log; the fail2ban module holds the seat from a runtime
|
||||
with the daemon's socket shared in, composes the jails, and the mail front end, the forge and the
|
||||
proxy each declare one. Through the console on the control node: `status` listed five jails with what
|
||||
each watches, `banned` listed the nine the long jail holds, and a documentation address banned in the
|
||||
ssh jail came back with its ban's end time and was released again. On the home server the proxy's jail
|
||||
had counted seven refusals within minutes of starting.
|
||||
|
||||
**One fault, found by the machine and not by a test.** The proxy's pattern matched two shapes of
|
||||
refusal in one expression and so named `<HOST>` twice. fail2ban expands that placeholder into a named
|
||||
capture group; two of them is a duplicate group name, and the daemon refuses *its whole configuration*
|
||||
and exits — both servers kept no bans at all for about ten minutes, every jail and not the one at
|
||||
fault. The pattern is now one per shape. A manifest check refuses the mistake at merge time, naming
|
||||
what it would cost, which is the only reason this record can claim the rule rather than the instance.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md), [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
|
||||
- [Design 31 — A module declares its fail2ban jail](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md), [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md), [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md)
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
|
||||
---
|
||||
|
||||
# 180. The found front end is uninstalled once a machine is converged
|
||||
|
||||
> **Renumbered 2026-10-02.** Written and merged as 0175 while another record already held that number on main (one tool runtime per node, merged minutes earlier); `cycle.py` refused main. The branch that lands last renumbers: 0178 and 0179 are claimed by open changes, so this is 0180. Nothing cited it by number.
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) retires the firewall a machine
|
||||
was found with by disabling it, never flushing it, and keeps its configuration on disk so that
|
||||
returning the node to adopted can enable it again. [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)
|
||||
made the host keep it retired and say so. Both machines of this mesh that had a front end have been
|
||||
converged for days; neither is going back. What remained of the front end on each — its package,
|
||||
its unit enabled for boot on one, its empty chains still wired into the kernel's hooks, a chain of
|
||||
its container integration still dropping traffic on the IPv6 path until the day before this record
|
||||
— was not a rollback path. It was software nobody runs, left where a reader finds it and asks
|
||||
whether the machine has two firewalls.
|
||||
|
||||
The operator asked on 2026-10-02 that it be disabled and uninstalled. Disabled it already was. For
|
||||
uninstalled, the host had no word: a package could be declared present and not absent.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A package may be declared absent.** `absent: true` on a package resource has the host remove
|
||||
the package when it is installed and leave alone a machine that never had it, through the machine's
|
||||
own package manager, dependencies untouched. A declaration that stops saying a package is absent
|
||||
installs nothing: there is nothing to undo.
|
||||
|
||||
**2. The module that holds the packet filter seat declares the front end it replaced absent**, after
|
||||
its own filter is loaded, so the mesh's table is in force before the front end's package goes. On a
|
||||
converged machine the front end is therefore gone, not merely off; on an adopted machine nothing of
|
||||
this runs, because the filter module is assigned by the flip and not before.
|
||||
|
||||
**3. Returning such a machine to adopted enables nothing.** A machine with no firewall needs no
|
||||
openings ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)); the host records the
|
||||
front end as *removed*, says so once, and asks nothing of a command that is not there. What
|
||||
ADR 0100 kept on disk for a return is kept only as far as the package manager keeps a changed
|
||||
configuration file; the rollback path it described is given up on purpose.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The host's vocabulary grows by `absent` on a package; an older host refuses a declaration
|
||||
carrying it, so the host rolls before the module.
|
||||
- The nftables module's declaration gains one resource; on the two machines of this mesh that were
|
||||
found with ufw, the next push removes it.
|
||||
- `node show` reads *found firewall: ufw, removed* on those machines from then on.
|
||||
- ADR 0100's sentence about a return to adopted restoring the found firewall holds only while the
|
||||
front end is installed, which after this record it is not on a converged machine.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| An absent package is removed when present, left when not, and read back | host tests over a fake package manager |
|
||||
| An uninstalled front end is recorded as removed and nothing is asked of it | a host test with ufw missing on a converged apply |
|
||||
| Live | done 2026-10-02: both machines report ufw gone — `pacman -Q ufw` has no answer, `node show` says *removed*, `status` is well. The home server said *retired* for six hours after the package went, because this record's step runs only after a clean apply ([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)) and one dead tracker was failing its applies ([ADR 0187](0187-a-dead-tracker-is-not-the-machines-failure.md)) |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)
|
||||
- [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md)
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-27
|
||||
deciders: jochen
|
||||
reconstructed: true
|
||||
extends: 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||
---
|
||||
|
||||
# 181. The operator account is a node fact, and a home is a placement root
|
||||
|
||||
> **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
|
||||
two rules the code left implicit — what an empty account means for a module, and that the account is
|
||||
stated rather than discovered. Written 2026-10-02.*
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) took every host path out of a
|
||||
module definition and gave a module's *system* data a place: a directory the mesh resolves under the
|
||||
node's root, owned by the module. It said nothing about the other half of a filesystem — the files
|
||||
that belong under a person's home and are owned by that person. The predecessor wrote several of
|
||||
those: the ssh client configuration, the shell's configuration, an agent's instruction files. It knew
|
||||
whose home it was writing into because each of its node records carried a login name. The mesh took
|
||||
the machine facts over and dropped the human one.
|
||||
|
||||
The loss was found the ordinary way: `ssh <node>` logged into the home-server under the workstation's
|
||||
own login name, because nothing in the mesh said the home-server's account was a different one
|
||||
([to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md),
|
||||
[issue 172](../04-ISSUES/172-the-ssh-client-block-matches-one-spelling-of-a-machine/00-report.md)).
|
||||
|
||||
What the controller does since 2026-09-27: a node record carries an operator account and, optionally,
|
||||
its home; the account and its home are machine facts a definition may name in a resource's path, owner
|
||||
and content; a roster file may say it lives under the home, and is then rendered per node, placed under
|
||||
that node's account's home, owned by the account, and left out on a node with no account. On
|
||||
2026-10-02 **all four nodes of the live mesh carry an empty account**: the fact exists and nobody has
|
||||
stated it, so no resource placed under a home can land anywhere yet.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The definition names the login.** `owner: <name>` in the module. Rejected: it is the installation
|
||||
written into a definition, which ADR 0112 forbids and
|
||||
[ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md) checks for, and it is
|
||||
wrong on the first machine whose login differs — which is exactly the machine that surfaced this.
|
||||
2. **The host discovers the account.** The first non-system user, or whoever ran the enrolment.
|
||||
Rejected: a guess. A shared machine has several people on it, a server may have none, and a host
|
||||
deciding whose files these are is a decision the mesh then cannot see, state or correct.
|
||||
3. **The account is a fact the operator states on the node record, and the home is derived from it
|
||||
unless stated.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A node has an operator account: the login name of the person who works on it.** It is stated by the
|
||||
operator on the node record, the way a node's address or mode is held there, and it is empty for a
|
||||
machine nobody logs into. Empty is a real state, not a missing value. The mesh holds the fact because
|
||||
everything below derives from it, and because it is precisely the fact that was lost when the
|
||||
predecessor's records were not carried over.
|
||||
|
||||
**The account's home is derived unless stated.** The superuser's home for the superuser, the
|
||||
distribution's conventional per-user home otherwise; a node whose account lives elsewhere states its
|
||||
home. One place computes the default, so a fact and the record cannot disagree about it.
|
||||
|
||||
**A resource may be placed under the home, owned by the account.** This is ADR 0112's move one level
|
||||
over: as a module's system directory is resolved under the node's root, a file under a person's home is
|
||||
resolved against the account's home, and owned by the account rather than by root or a module's own
|
||||
account. A definition names the account and its home as machine facts, never as a path; a roster fact
|
||||
may say it is a home file and is then placed and owned the same way. The controller resolves both at
|
||||
composition, and the host chowns what it creates.
|
||||
|
||||
**A node with no account cannot carry a 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
|
||||
is the right refusal.
|
||||
|
||||
**One account per node is what this record decides.** Several people on one machine is left open, with
|
||||
the constraint that allowing it must not force the common case — one workstation, one person — to name
|
||||
anything.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The operator states the account before any 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.
|
||||
- A family of modules becomes writable: everything the predecessor placed under a home — ssh client,
|
||||
shell, the agent's instruction files — is now a module naming a fact rather than a path
|
||||
([to-be 29 §2](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)).
|
||||
- **What got harder:** a definition cannot say "my user's home" without the mesh knowing who the user
|
||||
is, so a module of this family is refused on a freshly enrolled machine until a person is named on
|
||||
it. That is a prompt, not an obstacle.
|
||||
- **Not decided here:** several accounts per node; a service unit running as the account rather than
|
||||
as root or a module; a one-off step run as the account. Each is a record of its own.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A resource's path and owner resolve the account and its home | controller tests on machine-fact resolution: a file naming the account facts lands under the account's home, owned by the account |
|
||||
| The home is derived unless stated | a controller test: the superuser's home for the superuser, the conventional home otherwise, the stated home when one is stored |
|
||||
| A home roster fact is left out on a node with no account | a controller test on roster composition: the file is absent from that node's declaration and present on a node with an account |
|
||||
| A resource naming the account on a node with no account is refused by name | a controller test on machine-fact resolution: the refusal names `account` and lists the facts the machine does have |
|
||||
| No definition names a home path | ADR 0112's catalogue test on host paths, which a `/home` or `/root` literal fails |
|
||||
|
||||
## References
|
||||
|
||||
- [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the design this record
|
||||
gives a foundation to, and its "what has shipped" section
|
||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — the system-path placement
|
||||
this mirrors; [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md) — why
|
||||
a login name may not be in a definition
|
||||
- [ADR 0120](0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster fact a home file
|
||||
may be
|
||||
- [ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) — what
|
||||
the mesh may and may not do inside the home this record lets it reach
|
||||
- mesh-controller `internal/inventory/nodes.go` (the account and its home on the node record),
|
||||
`internal/catalogue/machine_into_files.go` and `roster.go` (resolution and the home fact)
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md
|
||||
---
|
||||
|
||||
# 182. Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found
|
||||
|
||||
> **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
|
||||
place files under a person's home. A home is unlike any directory the mesh has written into so far:
|
||||
it is shared with the person, and with every program the person runs. The agent's configuration
|
||||
directory on the laptop makes the point. On 2026-10-02 it holds thirty entries. The predecessor placed
|
||||
five of them (an instruction file, a conventions rule, a settings file it merged into, two skills); a
|
||||
sibling module placed a sixth (the node's identity rule). The agent itself writes the other
|
||||
twenty-four: its settings, its credentials, its history, the memory of every project it has worked in,
|
||||
its plugins, its session logs. Several of those are what [to-be 15](../03-DESIGN/01-to-be/15-the-agent-session.md)
|
||||
calls memory *written by the session itself and declared by nobody*: a mechanism that regenerated the
|
||||
directory would erase a season of it, silently, while reporting success.
|
||||
|
||||
The predecessor's own module recorded the hazard in the other direction. Its settings file was first
|
||||
shipped as *replace*, and every `/model` choice a person made inside a session was reverted to the
|
||||
template's value on the next synchronisation — on every node, indefinitely, with no indication why. It
|
||||
was changed to *merge*, and the comment explaining why is still in its manifest.
|
||||
|
||||
**And the generator is gone while its output stayed.** The predecessor was retired from the laptop on
|
||||
2026-10-01. Its six files are still on both workstations, with their content telling every session to
|
||||
use tools that no longer exist. Nothing owns them; nothing will ever rewrite or remove them.
|
||||
|
||||
[To-be 29 §3](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) drew the line for one directory,
|
||||
`~/.ssh`: the mesh owns the directory and the files it places; it holds the person's private keys and
|
||||
personal drop-ins as found. That was argued from the lockout `~/.ssh` can cause. The argument here is
|
||||
the same shape with a different stake — the person's work rather than the person's way in — and it has
|
||||
to hold for every directory under a home that any module will touch, so it is a rule, not a
|
||||
section.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The module owns the directory whole**, regenerating it from the definition. Rejected: it destroys
|
||||
the memory, history and local settings the agent writes for itself, which is the failure to-be 15
|
||||
names and the predecessor's settings file demonstrated at small scale.
|
||||
2. **The module owns only the files it names, and nothing about the directory.** Rejected: *owning one
|
||||
file beside foreign ones is not owning anything* (to-be 29). The directory must exist, with the right
|
||||
owner and mode, before the tool first runs on a fresh machine; and a credentials file in a
|
||||
world-readable directory is a credentials file in the wrong directory.
|
||||
3. **The module owns the directory and the files it places; a file the tool writes for itself is
|
||||
written into, never over; everything else is held as found.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A 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
|
||||
declared**, not inferred from what happened to be on disk:
|
||||
|
||||
| class | declared as | the host's rule |
|
||||
|---|---|---|
|
||||
| **owned** | a file with content, or a roster fact | written whole, regenerated, removed when undeclared; a file found there with no record of the mesh making it is kept once before it is written over ([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)) |
|
||||
| **written into** | a file written *into* a structured document | only the keys the definition names are set, every other key is kept, and each set key is given back when undeclared (ADR 0102). The key list is the module's and is short |
|
||||
| **written by the module's own process** | nothing the host applies: the module's code writes it from what it was handed | the file's content is never a declared file's content, because a declaration travels in the clear on the bus and the host records it; the module's code writes it, owned by the account, atomically. [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) says how for a credential |
|
||||
| **found** | nothing | never read, never rewritten, never removed. The person's memory, history, projects, local settings, their own rules and skills |
|
||||
|
||||
**A file the tool writes for itself is written into, never over.** The agent's settings file and its
|
||||
own state file are the tool's; the mesh has one or two facts to state in each. Setting those keys and
|
||||
nothing else is what lets a person's `/model` choice survive a push, and what lets the mesh's keys be
|
||||
taken back cleanly when the module goes.
|
||||
|
||||
**A predecessor's output is found.** A file placed by a generator that no longer exists is, to the
|
||||
mesh, a file it has no record of making. Where the successor module keeps the path, declaring it
|
||||
*adopts* it: the host keeps the original once and writes the mesh's. Where the successor does not keep
|
||||
the path, the mesh does not remove the file, because it removes nothing it did not make; **the operator
|
||||
removes it, once**, and the module's definition names those paths in its own documentation so the step
|
||||
is not forgotten. This is the first instance of the one-off setup step to-be 29 leaves open, and the
|
||||
rule chosen for it is that it is a person's act, listed, not a module's.
|
||||
|
||||
**The rule is the family's.** An ssh client module, a shell module, an agent module each declare their
|
||||
directory and classify their paths this way. A module that cannot say which class a path is in has not
|
||||
finished its definition.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A person's work under their home survives every push and every unassign. The mesh's own files come
|
||||
and go with the module; the mesh's keys in the tool's files come and go with it; the directory stays.
|
||||
- **Stale files survive too.** Two workstations keep three predecessor files each until a person removes
|
||||
them — a visible cost, accepted over a mesh that deletes under a person's home. A module author who
|
||||
renames one of the mesh's own files has the ordinary path: the old resource id is undeclared and the
|
||||
host removes what it made ([ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md)).
|
||||
- A module's definition is longer by a classification, and a reviewer has one more question per path.
|
||||
That is the point: *which parts are managed must be explicit rather than inferred* (to-be 15).
|
||||
- **What got harder:** a module cannot seed a person's preference once and leave it. A seeded file
|
||||
([ADR 0087](0087-a-seeded-file-is-created-once.md)) is the shape for that, and it is available to
|
||||
this family unchanged; what is refused is a seed the module later wants to change, because what grew
|
||||
in it is the person's.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The directory is created owned by the account and kept when the module goes | host tests of a directory resource with an owner (ADR 0051's and 0118's), and the family's lab check below |
|
||||
| An owned file found with no record is kept once, then written | host tests of ADR 0102's kept-original rule |
|
||||
| Only the declared keys of a written-into file change, and are given back | host tests of ADR 0102: declared keys set, the rest kept, restored when undeclared |
|
||||
| Nothing found is touched | the family's lab check: a machine with a seeded home holding a person's file beside a predecessor's; after apply the person's file is byte-identical, the predecessor's is kept as the original, the mesh's keys are set and the person's keys in the same file remain; after unassign the mesh's files are gone, the keys are restored, the person's files are untouched and the directory stands |
|
||||
| Every path a 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
|
||||
|
||||
- [to-be 29 §3](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) — the same boundary drawn for `~/.ssh`
|
||||
- [to-be 15](../03-DESIGN/01-to-be/15-the-agent-session.md) — a session's memory is declared by nobody
|
||||
- [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0087](0087-a-seeded-file-is-created-once.md),
|
||||
[ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md), [ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md) — the mechanics each class rests on
|
||||
- [ADR 0051](0051-shared-data-is-the-operators.md) — the third case the host had no word for: what it neither made nor configured
|
||||
- the predecessor's `claude-code` module manifest, whose comment on `strategy: merge` records the reverted `/model` choice
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0024-model-access-is-a-provision.md
|
||||
---
|
||||
|
||||
# 183. The Anthropic licence manager is a module holding a seat; it hands each node's agent its token over the bus, sealed; the controller and the host have no part
|
||||
|
||||
## Context
|
||||
|
||||
**The operator's stance, set on 2026-10-02 and sharpened during the day.** The controller has no part in
|
||||
the agent module. The host is module-agnostic: it knows no vendor, no agent, no path under a home. The
|
||||
agent module owns its own files. And there must be a *real* licence manager — a module that doles out
|
||||
the correct licence in every situation the mesh has: two subscription accounts and one API key today,
|
||||
used by a person's interactive agent on each workstation, by the mesh's own sessions, and by workers.
|
||||
|
||||
**What the predecessor built, read from its code the same day.** Two modules, split after an incident.
|
||||
A *manager* on exactly one node held every account's full OAuth grant encrypted, rotated each grant
|
||||
under a per-licence lease on a cadence and an expiry floor, published each rotation over its bus with
|
||||
the tokens encrypted, collected the vendor's usage figures per licence, and alerted once a day on
|
||||
repeated failure or on a refresh token within three days of its own expiry. A *consumer* on every node
|
||||
was the single writer of the agent's credentials file: it applied a published rotation, stripped the
|
||||
refresh token so a node could never rotate, pulled when stale, refused a stale grant by comparing
|
||||
expiries within one lineage, and mirrored a local login back to the manager only after checking the
|
||||
account's identity against the licence's record — because an unchecked mirror had once written one
|
||||
account's grant into another's row and published it mesh-wide. Three **touchpoints** with fallbacks: the
|
||||
node's interactive agent; the mesh's own sessions on the node, falling back to the node's licence; a
|
||||
worker's own account, falling back to the node's, and refusing to spawn when assigned a licence that
|
||||
could not be served. The split exists because four nodes refreshing one grant destroyed it: an OAuth
|
||||
refresh rotates the refresh token, and the predecessor's own code records both that a reused token
|
||||
killed a licence and that a malformed client id was once misdiagnosed as the same fault. **Whether a
|
||||
refresh token is single-use is not documented by the vendor**; the predecessor treated it as so, and
|
||||
this record keeps one rotation source for that reason while leaving the fact to be measured.
|
||||
|
||||
**What the mesh has.** [ADR 0050](0050-model-access-is-vendor-agnostic.md) put a per-vendor adapter
|
||||
inside the controller's licences context, with the carve-out that the manager node holds the refresh
|
||||
token readably; the catalogue has a manager and a consumer module built on it, assigned to nothing. The
|
||||
controller's licence commands are not seat verbs and cannot be asked for through the console
|
||||
([to-be 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)). `model-access` is a vendor-blind
|
||||
provision ([ADR 0024](0024-model-access-is-a-provision.md)), and the operator's judgement is that the
|
||||
agent is not a vendor-blind consumer: it is coupled to an Anthropic subscription grant and nothing else,
|
||||
so a name that hides the vendor misdescribes the coupling
|
||||
([ADR 0027](0027-a-provision-names-what-the-consumer-is-coupled-to.md)).
|
||||
|
||||
**The bus's rule for a secret** ([to-be 32 §10](../03-DESIGN/01-to-be/32-what-a-module-declares.md)): the
|
||||
bus is not trusted with one; a secret travels sealed to its recipient, on core request/reply, never
|
||||
through a stream that persists it.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the lifecycle in the controller** ([ADR 0050](0050-model-access-is-vendor-agnostic.md) as
|
||||
built), and make the agent module a consumer of `model-access` delivered by the host as a sealed
|
||||
file. Rejected by the operator: the controller and the host would both carry a part of an
|
||||
Anthropic-specific mechanism, and the agent's coupling is misnamed.
|
||||
2. **The manager delivers each short-lived token through the vault**, as a backend-issued secret the
|
||||
vault provides to each consumer ([ADR 0113](0113-the-vault-makes-every-secret.md)). Rejected: every
|
||||
hourly rotation becomes a vault delivery, a composition and a push to every node, and the host
|
||||
ends up writing a vendor's credential as a file — the module-agnostic host, carrying a vendor's
|
||||
traffic.
|
||||
3. **A seat-holding manager module that talks to the agent module on every node over the bus.**
|
||||
Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Anthropic licence manager is a module, `claude-licence-manager`, holding the mesh-scoped seat
|
||||
`anthropic-licence-manager`.** The seat's contract is the licence verbs: list the licences and their
|
||||
health, list the bindings, bind or switch a consumer, release one, refresh now, read usage, adopt a
|
||||
grant, register a node's key, answer a consumer's current token. One holder, on a node the operator
|
||||
assigns, is what makes rotation happen once ([ADR 0126](0126-a-module-declares-its-own-seats.md),
|
||||
[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)). The seat is named for the vendor,
|
||||
because what it manages is one vendor's grants and nothing else is coupled to it. The vendor-blind
|
||||
`model-access` provision stands for the consumers that do not care which vendor answers; the agent is
|
||||
not among them.
|
||||
|
||||
**The manager owns the licences.** The records, the grants, the bindings per touchpoint, the usage
|
||||
readings and the audit of every switch live in the manager's own store, not in the controller's
|
||||
licences context, which keeps only what it already serves to vendor-blind consumers. The manager is the
|
||||
one rotation source: it alone calls the vendor's token endpoint, under a lease per licence, on an expiry
|
||||
floor and a cadence it declares as a setting.
|
||||
|
||||
**The long-lived grants are encrypted at rest with a key the vault made for the manager.** The vault
|
||||
keeps custody of that one key as the manager's own secret ([ADR 0113](0113-the-vault-makes-every-secret.md));
|
||||
the grants themselves — a refresh token per subscription account, the API key — are the manager's
|
||||
rows, readable only by it. This is [ADR 0050](0050-model-access-is-vendor-agnostic.md)'s carve-out,
|
||||
moved with the manager: *one module, one node, the long-lived grants only.*
|
||||
|
||||
**The short-lived tokens travel module to module, sealed, on request/reply.** The agent module on each
|
||||
node makes a keypair of its own when it first runs — a private key made where it is used, never leaving
|
||||
([ADR 0113](0113-the-vault-makes-every-secret.md)) — and registers its public half with the seat. The
|
||||
manager hands a node its token by calling that node's agent module (`<module>.<tool>@<node>`,
|
||||
[ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)) with the token
|
||||
sealed to that key, and the module answers *applied* or *refused* and why. An agent module that starts,
|
||||
or finds its token near expiry, asks the seat for its current token the same way. **A token is never
|
||||
published as an event**: what the manager emits — rotated, switched, failing, usage read — names the
|
||||
licence and nothing secret, and the audit logger records it. This is a second channel for a secret
|
||||
beside the vault's, and it is bounded as 0050's carve-out is: this vendor, tokens that live hours, sealed
|
||||
to one recipient, request/reply only.
|
||||
|
||||
**The agent module alone writes what the agent reads.** For a subscription licence it writes the
|
||||
agent's credentials file under the operator's home, as the operator, access-token-only, atomically. For
|
||||
the API-key licence it serves the key through the agent's own key-helper setting, so nothing is written
|
||||
under the home at all. For the mesh's own sessions and workers on that node, it is the local source of
|
||||
their token. **The host delivers the module's package and its state directory and knows nothing else**:
|
||||
no path under the home, no vendor, no file shape.
|
||||
|
||||
**A binding is explicit, and a switch is a reaction.** Every consumer — a node's interactive agent, the
|
||||
mesh's session on a node, a worker — is bound to a licence by the operator through the seat's verb, with
|
||||
the predecessor's fallbacks: a session inherits its node's licence, a worker inherits its node's, and a
|
||||
worker assigned a licence that cannot be served is refused rather than lent another. Exhaustion is
|
||||
observed and warned about once per crossing of a declared threshold; moving a consumer to another
|
||||
licence is a person's act through the seat's verb, as [ADR 0024](0024-model-access-is-a-provision.md)
|
||||
says, and the declaration language grows no conditional. An automated policy is not decided here.
|
||||
|
||||
**A login is attributed only to the account it belongs to.** When a person logs in on a node, the
|
||||
agent module reads the account's identity from the agent's own state and offers the grant to the
|
||||
manager sealed to the manager's key; the manager adopts it only when the identity matches the licence
|
||||
the node is bound to, and refuses with a notification otherwise.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One module decides which licence every consumer gets, one module writes what each agent reads, and
|
||||
neither the controller nor the host carries a word of the vendor.
|
||||
- **A second sealed channel exists** beside the vault's, bounded as stated. A record that widens it to
|
||||
another vendor or a longer-lived secret is a new decision, not an application of this one.
|
||||
- The catalogue's `anthropic-manager` and `anthropic-consumer` modules, built on ADR 0050's placement,
|
||||
are retired once the manager runs; the controller's licences context stops holding Anthropic licences.
|
||||
- The console lists the seat's verbs, so a person switches a licence in a sentence, and the controller
|
||||
gains no `licence` verb.
|
||||
- **What got harder:** a manager that is down leaves every node on its last token until it expires;
|
||||
the agent module keeps the last token and says so. And a node whose agent module has not registered
|
||||
its key cannot be handed a token, which the manager reports by name.
|
||||
- Every interactive session on a machine shares the node's one agent directory, and so its licence;
|
||||
twenty sessions share it as one does. A consumer with a licence of its own on the same machine is a
|
||||
worker running from a home of its own with its own agent directory — the worker touchpoint above, for
|
||||
when workers exist ([ADR 0003](0003-agents-are-persistent-employees.md)); the predecessor ran its
|
||||
agents that way.
|
||||
- **Not decided here:** an automated switch on exhaustion; whether a refresh token is single-use, to be
|
||||
measured in the lab.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Only the seat's holder calls the vendor's token endpoint | a catalogue test: no module but the manager names it; the manager's refresh runs under a lease per licence, tested with two concurrent runs |
|
||||
| A token crosses the bus only sealed, only on request/reply | a bus test: every message the manager publishes as an event carries no token; the hand-over is a request whose payload opens only with the receiving module's key |
|
||||
| The agent module's private key never leaves the node | the per-key test of ADR 0113, extended to this module's key |
|
||||
| The host writes nothing under a home and names no vendor | a catalogue test on the agent module's definition: no file resource under a home, no vendor word in anything the host applies |
|
||||
| A grant is attributed only to a matching identity | a manager test: a grant whose account identity differs from the bound licence's is refused and a notification emitted |
|
||||
| An unservable binding refuses rather than lends | a manager test: a worker bound to a dead licence is answered with a refusal, never another licence's token |
|
||||
| A switch through the console changes the token on the node and nothing in the answer is a token | a live check on one workstation |
|
||||
|
||||
> **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
|
||||
- [ADR 0027](0027-a-provision-names-what-the-consumer-is-coupled-to.md) — why the seat is named for the vendor
|
||||
- [ADR 0113](0113-the-vault-makes-every-secret.md) — the vault's custody of the manager's key, and the exception stated here
|
||||
- [ADR 0126](0126-a-module-declares-its-own-seats.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md) — a module's seat, its verbs, a call addressed to one machine
|
||||
- [to-be 32 §10](../03-DESIGN/01-to-be/32-what-a-module-declares.md) — a secret on the bus
|
||||
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md), [to-be 39](../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md) — the two modules
|
||||
- the predecessor's `claude-licences` and `claude-code` modules, read 2026-10-02: the lease, the floor, the lineage comparison, the identity guard, the touchpoints
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0005-the-node-host.md
|
||||
---
|
||||
|
||||
# 184. A service the mesh asked to run is still running a moment later
|
||||
|
||||
## Context
|
||||
|
||||
The host already refuses to take a service manager's word for it. Three places in one function read
|
||||
a unit back after acting on it, each with a comment saying why: *a service manager accepting a
|
||||
command says the transaction was accepted, not that the unit is running — one that starts and
|
||||
immediately dies satisfies it.* The intent was right and the implementation did not reach it.
|
||||
|
||||
On 2026-10-02 the mesh composed a fail2ban jail whose pattern the daemon refused. The host wrote the
|
||||
files, restarted the service, read the unit back and reported *restarted*. The unit was `active` at
|
||||
that instant and `failed` 221 milliseconds later, which the unit's own record states. Both public
|
||||
machines then kept no bans at all — every jail, not the one at fault — and nothing in the mesh said
|
||||
so. The fault was found by calling a tool that needed the daemon, not by the mesh noticing.
|
||||
|
||||
The read-back races the failure. A service manager returns when it has started the process; a daemon
|
||||
that reads its configuration, refuses it and exits does so a fraction of a second afterwards. One
|
||||
look sees `activating` or `active` whatever the process is about to do, and *the host reports success
|
||||
for a machine that is already wrong* — the one shape of failure this host exists to refuse
|
||||
([ADR 0005](0005-the-node-host.md)).
|
||||
|
||||
A command the module declares — *test the configuration before restarting* — was considered and
|
||||
rejected. The link carries no actions ([ADR 0005](0005-the-node-host.md)), and a verification
|
||||
command is a command: a declaration that carried one would be remote execution over the bus,
|
||||
arriving as root on every machine, which is a far larger door than the fault it closes. The host
|
||||
does not need one. It already knows what it asked for.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A unit the host has just asked to run is read twice**, with a pause between the reads long
|
||||
enough for a daemon that refuses its configuration to have exited. Not running at the second look is
|
||||
a failure of that resource, named with the unit and the state it is in — the same failure the single
|
||||
read was always meant to catch.
|
||||
|
||||
**2. It is never a wait for a unit to come up.** A unit still starting reads as running at both
|
||||
looks and is accepted, exactly as before. What the second look catches is a unit that *was* running
|
||||
and is not any more. A service asked to be stopped is not waited on at all.
|
||||
|
||||
**3. The host tests nothing and runs nothing of a module's.** The second look is the host checking
|
||||
the state it was told to establish, which is its whole job; the declaration gains no vocabulary, and
|
||||
no command reaches a machine that did not already come from a built artifact.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every apply that starts, restarts or reloads a service spends a moment confirming it. The cost is
|
||||
bounded by the number of services that changed in that apply, which is usually none.
|
||||
- A module whose configuration the mesh composes — the packet filter, the intrusion prevention, the
|
||||
resolver — now fails its apply when the composition is bad, instead of reporting success onto a
|
||||
dead daemon. `status` names the machine, which is how the operator finds out.
|
||||
- It does not prevent the bad composition. [ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)'s
|
||||
manifest check is what refuses the one that caused this, at merge time; this record is what makes
|
||||
the *next* one visible within a minute rather than invisible until something asks the daemon a
|
||||
question.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A unit that is running at the first look and dead at the second fails the apply, naming the unit and its state | a host test over a service manager that answers as systemd does |
|
||||
| A unit still starting is accepted at both looks | a host test |
|
||||
| A service asked to be stopped is not waited on | a host test |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0005](0005-the-node-host.md), [ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)
|
||||
- [Design 05 — The node host](../03-DESIGN/01-to-be/05-the-node-host.md)
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md
|
||||
---
|
||||
|
||||
# 185. A control plane behind its seat's row serves what it can
|
||||
|
||||
## Context
|
||||
|
||||
The mesh's own verbs are the controller seat's tools, and the seat's row is the store's
|
||||
([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)). A control plane reads the
|
||||
row at start and installs a handler per verb; a verb the row carries that the binary cannot run was
|
||||
refused at start rather than at the first call, so that a disagreement between the row and the
|
||||
binary was said early. The refusal aborted the start.
|
||||
|
||||
On 2026-10-02 a merge added one verb. The new control plane started, widened the row, and ran. A
|
||||
push a few seconds later recreated its container at the previous image — a stale declaration from
|
||||
an overlapping wave, [issue 201](../04-ISSUES/201-a-push-recreated-the-controller-behind-the-row-its-successor-wrote/00-report.md) —
|
||||
and the older binary read a row naming a word it had never heard. It refused to start, and kept
|
||||
refusing. The mesh had no voice for ten minutes: no verb answered, no node could be pushed, no build
|
||||
was dispatched, and `status` said nothing because `status` is one of the verbs that had stopped
|
||||
being served. The way back was a person running the binary by hand outside its service, because the
|
||||
push that would have replaced it is itself a verb of the control plane that was down.
|
||||
|
||||
The check was right about the fact and wrong about the cost. A row ahead of a binary is the ordinary
|
||||
state of a roll-out: the row is widened by whichever control plane starts first, and a mesh with one
|
||||
control plane sees that gap on every merge that adds a verb. Making it fatal turned a transient into
|
||||
an outage with no path out that did not need a human.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A control plane serves the verbs it can run and does not refuse to start for the ones it
|
||||
cannot.** The row remains the authority on what the seat serves; this is only about what this binary
|
||||
does when it is behind the row.
|
||||
|
||||
**2. A verb it cannot run answers the reason.** Not silence and not a missing subject: a caller gets
|
||||
a sentence naming the verb, saying this control plane cannot run it and that it is a verb of a newer
|
||||
build. A verb that is simply absent from the row is still not served at all — that is the row
|
||||
deciding, which is unchanged.
|
||||
|
||||
**3. It says so once at start**, naming every verb of the row it cannot run, so the gap is visible
|
||||
in the log of the thing that has it rather than only at the moment somebody calls one.
|
||||
|
||||
**4. A mesh with no controller seat at all is still a refusal.** That is not a version gap, it is a
|
||||
mesh that has not been seeded, and nothing this control plane does would be meaningful.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An overlapping roll-out costs the verbs the newer build added, for as long as the older binary is
|
||||
in place. Everything else — every push, every build, every read — keeps working, and the ordinary
|
||||
machinery that notices a machine is behind is what puts the newer binary back.
|
||||
- The log gains one line on a control plane that is behind, and nothing on one that is not.
|
||||
- Issue 201's other half remains: the push that sent a stale declaration is a race worth closing on
|
||||
its own terms. This record makes that race survivable rather than fatal, which is the difference
|
||||
between a transient and an outage, and is deliberately the cheaper half.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A row carrying a verb this build cannot run still serves every verb it can, and names the one it cannot | a controller test over a widened row |
|
||||
| The unknown verb answers a sentence naming itself and saying this build is behind | the same test |
|
||||
| A mesh with no controller seat is refused | the existing start-up path |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
|
||||
- [Issue 201](../04-ISSUES/201-a-push-recreated-the-controller-behind-the-row-its-successor-wrote/00-report.md)
|
||||
- [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md
|
||||
---
|
||||
|
||||
# 186. A ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md) gave the
|
||||
public proxy a jail. Within the hour the home server's ban list held `192.168.1.1` — the house's own
|
||||
router. The router reflects local traffic, so every client in the building reaches that machine as
|
||||
the gateway's address; one local request for a name the mesh does not serve, three times in a day,
|
||||
and the whole house is refused by the machine it was asking. The jails inherited an `ignoreip` of
|
||||
the loopback and the mesh's own range, which was right when the only jail read the ssh daemon and
|
||||
the only clients were the mesh's; a jail on a public front door sees the neighbours too.
|
||||
|
||||
The same jail broke the other half of [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md).
|
||||
The home server began reading *NOT the mesh alone: 1 rule set the mesh did not write refuses traffic
|
||||
here*, and the rule set named was the mesh's own ban chain, written by the mesh's own intrusion
|
||||
prevention minutes earlier. The host's reader of the legacy filter required every path into a chain
|
||||
of refusals to come from a built-in chain whose policy accepts, before it would call that chain a
|
||||
ban. On that machine the chain hangs off the container runtime's user chain as well as the input
|
||||
chain, and the runtime had set the forward policy to DROP — so the mesh reported its own work as a
|
||||
foreigner's, on the one machine where the group's exit condition was supposed to hold.
|
||||
|
||||
Both faults are one mistake in two places: a rule written about the public internet, applied to
|
||||
everything that arrives.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A ban list never holds a neighbour.** The jails the mesh composes never ban a source on a
|
||||
private range — the mesh's own range, which was already named rather than written
|
||||
([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)), and every address space
|
||||
reserved for private use beside it, in both families. A machine behind a router that reflects local
|
||||
traffic sees its whole building as one address; a ban there is a self-inflicted outage, and the
|
||||
sources worth banning are not on those ranges in the first place.
|
||||
|
||||
**2. The mesh's own bans are its own wherever they hang.** A chain of refusals is a ban list when
|
||||
every refusal names the sources it refuses and the chain accepts nothing — the rule the host already
|
||||
applied to the packet filter's own tables, now applied to the legacy filter too, and nothing more.
|
||||
The policy of the chains that jump into it says nothing about what it is: that policy is already
|
||||
classified where it belongs, as the container runtime's, and requiring it here counted it twice.
|
||||
|
||||
**3. A chain that accepts anything is still not a ban.** That is what keeps a predecessor's
|
||||
allow-these-and-drop-the-rest chain classified as something an operator must look at, which is the
|
||||
distinction [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md) exists to draw.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The composed jails gain the private ranges in their never-ban list. An address already banned
|
||||
stays banned until it is released; the house's router was released by hand the moment it was found.
|
||||
- The home server reads *the mesh alone* again, which is group 7's exit condition and was false for
|
||||
about an hour.
|
||||
- A machine whose apply fails for an unrelated reason does not revisit its found firewall's record
|
||||
at all — the step runs only after a clean apply ([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)).
|
||||
The home server's record therefore still reads *retired by the mesh* although the front end is
|
||||
uninstalled, and will correct itself once that machine's own stuck module is fixed. It is a stale
|
||||
record, not a wrong machine.
|
||||
- The record number the front end's removal was given moved under it: another session took 0175
|
||||
while that record was in review, and it is now
|
||||
[ADR 0180](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md). The citations
|
||||
the host and the control plane print were pointing at an unrelated record and are corrected here.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A private source is never banned | the module's jail configuration, read back by `fail2ban.fail2ban_settings` on a machine |
|
||||
| The mesh's own ban chain reads as a ban behind a dropping forward policy | a host test over the home server's own captured rule set |
|
||||
| A chain that accepts anything is not a ban | a host test |
|
||||
| Live | done 2026-10-02: all four machines read *the mesh alone*, the home server counting its own ban chain as a ban; no ban held anywhere is a private address |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0179](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md), [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0180](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md)
|
||||
- [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md), [Design 31 — A module declares its fail2ban jail](../03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md)
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0136-a-step-gates-its-module-not-the-machine.md
|
||||
---
|
||||
|
||||
# 187. A dead tracker is not the machine's failure
|
||||
|
||||
## Context
|
||||
|
||||
The home server had not applied a declaration cleanly since midday. One run-once step — the one
|
||||
that writes a media app's download clients and indexers through the app's own API — exited
|
||||
non-zero, forty-nine times over six hours, for one public tracker that had stopped answering. The
|
||||
step's own words: the entry was *written*, and the app's test of it then failed with a 400 from the
|
||||
indexer proxy. The machine reported *not doing what it was told* for the rest of the day.
|
||||
|
||||
What that gated matters more than the step. A converged machine retires the firewall it was found
|
||||
with only after a clean apply ([ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)),
|
||||
so that machine went on recording its found front end as merely *retired* long after the package
|
||||
had been uninstalled ([ADR 0180](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md)).
|
||||
A dead public tracker was holding a firewall record hostage, which is not a connection anybody
|
||||
would design.
|
||||
|
||||
The step already knew this was not its business. It had a rule for exactly this: an entry the mesh
|
||||
only *found and re-pointed*, rather than one it was told to make, whose feed is gone, is said and
|
||||
left as found — *failing the node's apply on every heartbeat for it reports the mesh as wrong about
|
||||
a tracker*. The rule was there and matched one shape of the fault. An app can refuse to save such an
|
||||
entry, and it can save it and then fail its own test; saving validates settings, and the test runs a
|
||||
live search. The rule caught the first and let the second through.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. An entry the mesh only found is never the machine's failure.** Whatever shape the app's
|
||||
refusal takes — it would not save it, or it saved it and its own test fails — an indexer the mesh
|
||||
found and re-pointed is reported as a notice and left as found. What decides is whose entry it is,
|
||||
not which sentence the app returned.
|
||||
|
||||
**2. What the mesh is answerable for is the plumbing.** That the entry exists, points at this
|
||||
mesh's indexer proxy, and carries the credential the mesh delivered — which was checked against the
|
||||
proxy before anything was written. Whether a public tracker answers today is not the mesh's to
|
||||
promise, and a machine that reports itself broken because one did is lying about itself.
|
||||
|
||||
**3. An entry the operator listed is theirs to insist on.** An indexer named in the step's settings
|
||||
is one the mesh was told to make, and it still fails the step when it cannot be made to work. The
|
||||
notice says so, and says that listing the indexer is how to turn it back into a failure.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The home server applies cleanly again, and everything a clean apply gates — its found firewall's
|
||||
record among it — follows.
|
||||
- A tracker that dies is a line in a report rather than a machine that reads as broken. An operator
|
||||
who wants it gone removes the entry or repairs the feed; the mesh says which, every time it runs.
|
||||
- The four Servarr modules carry one byte-identical copy of this step each
|
||||
([ADR 0069](0069-a-module-is-a-repository-and-a-path.md)), so the change lands in four places and
|
||||
a test refuses any drift between them.
|
||||
- It does not widen to a download client: one the mesh was told to write and cannot is still a
|
||||
failure, because the mesh chose it and nothing else will fix it.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A found feed whose tracker answers an error after the entry was written is a notice | the step's tests, with the home server's own message and the app's two validations modelled apart |
|
||||
| An indexer the settings list is still a failure | the same test |
|
||||
| The four copies of the step do not drift | the step's own sameness test |
|
||||
| Live | done 2026-10-02: the home server applies cleanly after six hours of failing, `status` holds no machine wrong or behind, and its found firewall reads *removed* |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0136](0136-a-step-gates-its-module-not-the-machine.md), [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0180](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md), [ADR 0069](0069-a-module-is-a-repository-and-a-path.md)
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
|
||||
---
|
||||
|
||||
# 188. A module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) put one
|
||||
tool runtime on every node and said a module brings its tools as a bundle. The runtime that exists
|
||||
is written in TypeScript and brings a bundle to life by **importing it into its own process**, which
|
||||
only JavaScript can be. The SDK ([ADR 0039](0039-what-the-sdk-holds-and-refuses.md)) is one
|
||||
TypeScript package. The builder knows three toolchains — TypeScript, Go, Python — and every one of
|
||||
the 35 catalogue modules with tools wraps them in a container on the runtime's TypeScript image.
|
||||
Nothing in the records says a module's code may be written in anything else, and nothing refuses a
|
||||
module that wraps its own code in an image to get around that.
|
||||
|
||||
The operator's direction, stated on 2026-10-02 and repeated: *the SDK is the most important part;
|
||||
we must not limit developers; tools can be written in any possible language — Rust, C, Go,
|
||||
JavaScript. A service in Go or Rust as a systemd unit must be possible too. One module can deliver
|
||||
all kinds of bundles: one for its tools, one for a seat's implementation, one for a daemon. Support
|
||||
the bare minimum first, as a skeleton; a full implementation comes when the work requires it.*
|
||||
|
||||
Measured against that: the `bundle` artifact kind already names a language and the `process`
|
||||
resource already runs a command from an unpacked bundle as a unit the host writes
|
||||
([ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)), so a Go
|
||||
daemon as a native service is possible today and one module in the catalogue does it. What is not
|
||||
possible is a tool in any language but one, and what is not written is that any of this is the
|
||||
rule.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **One SDK, one language, as now.** Rejected: it limits who can write a module to one
|
||||
ecosystem, which the operator declines, and it is what made every module's tools a container
|
||||
on one image.
|
||||
2. **A full bus client per language.** Each SDK speaks the bus itself; the runtime only
|
||||
supervises. Rejected: a transport in every SDK is what ADR 0039 refuses, and a bus change
|
||||
would then rebuild every module in every language — the cascade, multiplied.
|
||||
3. **A tools bundle is a process the runtime launches and speaks a small local protocol to,
|
||||
and that protocol is MCP over stdio.** Chosen. The runtime already speaks MCP outward (the
|
||||
console); speaking it inward to a child process is the same vocabulary. Every language that
|
||||
has an MCP server library can write a tools bundle today with no mesh SDK at all, and the
|
||||
mesh's own SDK for a language is a thin convenience over it. The transport stays in the
|
||||
runtime, so a bus change rebuilds nothing.
|
||||
4. **A protocol of the mesh's own design.** Rejected: a second way to describe a tool, its
|
||||
schema and its call, inventing what MCP already settled, for no gain.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A module's own code is bundles, in any language the mesh has a toolchain for, and never an
|
||||
image.** A `bundle` names its language and what it is for. Images are for third-party software a
|
||||
module installs — a database, a forge — never for code the module wrote. One module may declare
|
||||
several bundles: its tools, its implementation of a seat's verbs, a daemon, a step. Each is built
|
||||
alone and delivered alone, as [ADR 0156](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)
|
||||
already has it.
|
||||
|
||||
**2. A bundle the runtime serves is a process that speaks MCP over stdio.** The node's runtime
|
||||
launches it as the bundle names it — an interpreter and a file, or a binary — with the runtime's
|
||||
environment, asks `tools/list`, and answers each call on the bus by `tools/call`. A tool whose name
|
||||
is `<seat>.<verb>` is the module's implementation of that seat's verb; any other name is the
|
||||
module's own tool. Everything the runtime does with what it is told — subjects from the membership,
|
||||
a held seat's verbs, the `tools` answer, a bundle that fails named and the others serving — stays as
|
||||
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) and
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
||||
have it. A TypeScript bundle may still be imported into the runtime's own process; that is a
|
||||
shortcut over the same contract, not a second contract, and a TypeScript bundle written against
|
||||
the protocol is served the same way as any other.
|
||||
|
||||
**3. A bundle that is a service is a `process`**, run by the host as a unit, in whatever language it
|
||||
is compiled from, exactly as the host's own bundle already is. Nothing new is decided here; it is
|
||||
said so that it is the rule and not an example.
|
||||
|
||||
**4. One thin SDK per language, and the test of ADR 0039 applies to each.** An SDK for a language
|
||||
holds the MCP-over-stdio loop, the tool-definition type and the few primitives a module's code
|
||||
needs; it holds no transport, no module's client and nothing volatile. Where a language has a
|
||||
sound MCP library, the SDK wraps it rather than re-implementing it. The languages are those that
|
||||
make sense to write a module in; the first set is TypeScript, Go, Python, Rust and C, and the set
|
||||
grows when a module needs one, not before.
|
||||
|
||||
**5. Skeleton first.** Each piece — a toolchain, a launcher, an SDK — exists at the bare minimum
|
||||
that lets one bundle in that language be built, delivered and answer one tool on the live mesh.
|
||||
Anything beyond that is added when a module needs it. A skeleton that is not proven by one bundle
|
||||
answering is not a skeleton; it is a promise.
|
||||
|
||||
> **The mechanism changed — 2026-10-03, by ADR 0193.** §2's allowance that a TypeScript bundle may
|
||||
> be imported into the runtime's own process is withdrawn: every served bundle is launched, and the
|
||||
> build makes each served entrypoint executable. The rest of §2 stands.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The runtime gains a launcher beside its loader. The loader, the memberships, the seats and the
|
||||
failure handling built for ADR 0175 stand; the launcher is the one new step.
|
||||
- The builder gains a toolchain per language, each at the skeleton: compile, pack, name the
|
||||
entrypoint. Rust and C are new; a language that compiles to a binary says its operating system
|
||||
as a Go bundle already does.
|
||||
- An existing MCP server in any language is already a valid tools bundle. What the mesh adds is
|
||||
the subjects, the seats and the memberships around it.
|
||||
- The gate [to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP2 adds —
|
||||
refusing a tools container built on the runtime's image — widens: a module whose own code is
|
||||
an image artifact is refused at registration, naming this record.
|
||||
- What got harder: a tools bundle is now a process per module on the node rather than code in
|
||||
one process, so the runtime supervises children and restarts one that dies. The one-process
|
||||
shape ADR 0175 counted on for the TypeScript shortcut remains available for it.
|
||||
- ADR 0039's "what the SDK holds" now reads per language; its refusals are unchanged and are the
|
||||
reason option 2 was rejected.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A module's own code is never an image | the catalogue's registration check: a manifest with a `bundle` kind of own code *and* an image artifact built from the module's own directory is refused, naming this record |
|
||||
| A tools bundle in a language other than TypeScript answers on the bus | the runtime's tests: a bundle written against the protocol in a second language, launched, its tool called over a real bus |
|
||||
| A TypeScript bundle written against the protocol is served like any other | the same tests, with the TypeScript shortcut off |
|
||||
| Each SDK is thin | each SDK's own README states what it holds under ADR 0039's test, and its size is in the mesh's records |
|
||||
| Live | a tool in a compiled language answers from the node's runtime on one machine |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
|
||||
[ADR 0039](0039-what-the-sdk-holds-and-refuses.md),
|
||||
[ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md),
|
||||
[ADR 0156](0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md),
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
||||
- [To-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) — the work packages this
|
||||
record widens
|
||||
- The Model Context Protocol's stdio transport — the local protocol a tools bundle speaks
|
||||
@@ -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)
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
|
||||
---
|
||||
|
||||
# 190. A seat's work is shared by its holders, and building is the first such role
|
||||
|
||||
## Context
|
||||
|
||||
Work addressed to a role goes to the seat's `accept` subjects, on a per-seat work queue
|
||||
([design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1, [ADR 0041](0041-events-are-a-relationship.md)).
|
||||
The holder's worker on that queue is already a queue group — *"even though the seat guarantees one
|
||||
holder … the day somebody allows two holders for throughput, every message is processed twice with
|
||||
nothing reporting it"* — and design 25 already says what a build queue shared by several machines is:
|
||||
*a seat's `accept` subjects, on a work queue with a queue group of holders*. The mechanism was drawn.
|
||||
Two things stopped it being used.
|
||||
|
||||
First, the build role is a **mesh-scoped** seat, `mesh-build-machine`, so there is one holder in the
|
||||
whole mesh ([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md):
|
||||
*the mesh's single build machine*). Second, the worker is a push consumer with **one delivery in
|
||||
flight** — set so after 2026-10-01, when a push consumer handing out many at once left twenty-six of
|
||||
forty-three asks undelivered ([issue 175](../04-ISSUES/175-an-announcement-behind-a-long-build-comes-back/00-report.md)) —
|
||||
and one in flight on a shared consumer is one build at a time across every holder there could be.
|
||||
|
||||
Measured on 2026-10-02: a change to code comments in the tool runtime rebuilt its thirty-five
|
||||
dependent images, one after another, on one machine, for about half an hour, while three other
|
||||
machines with a container runtime sat idle; the work the mesh wanted next waited behind it. The
|
||||
operator's words: *this is our first occurrence of a mesh advantage* — and: *make sure the setup is
|
||||
done generically, so if another module also requires mesh functionality it can re-use the pattern.*
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **A second build machine by configuration** — a concurrency setting on the one holder, or a second
|
||||
holder admitted by hand. Rejected: a setting on one machine shares nothing, and a second holder
|
||||
of a mesh-scoped seat contradicts what a mesh seat means.
|
||||
2. **A build-specific dispatcher** — the controller choosing a machine per build and asking it by
|
||||
name. Rejected: it reinvents the queue the bus already is, it makes the controller a scheduler,
|
||||
and it is specific to building; the next role needing the same would build its own.
|
||||
3. **A seat's work is shared by its holders, and the build role becomes node-scoped.** Chosen. It is
|
||||
what the bus was drawn to do, it is one rule for every role rather than one for building, and
|
||||
"the machines that are online and hold the seat" is exactly the set a queue group's members is.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Work asked of a seat is taken by whichever of its holders is idle.** Every holder of a seat with
|
||||
`accepts` reads the seat's one work queue; a node-scoped seat held on several machines has several
|
||||
holders, and an ask goes to one of them. The asker addresses the role — `mesh.seat.<seat>.accept.<verb>`
|
||||
— and never a machine. The outcome, the role's own event, says which machine did the work (`on`), as a
|
||||
build's already does ([ADR 0157](0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md)).
|
||||
|
||||
**2. A holder takes one ask at a time, when it is idle, by pulling.** The worker is a pull consumer:
|
||||
a holder fetches one ask, works it, acknowledges, fetches the next. The server never hands an ask
|
||||
to a busy holder, so a slow machine never holds work an idle one could take — the fault issue 175
|
||||
found in push delivery is removed by the shape rather than by a limit, and the one-in-flight limit
|
||||
that made the shared queue serial goes with it. A holder that dies mid-work leaves its ask to be
|
||||
redelivered to another, as today.
|
||||
|
||||
**3. Work that must run on one particular machine is not a work queue.** That is a node seat's verb
|
||||
asked of that machine ([design 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §4), and
|
||||
nothing here changes it. A role's work queue is for work whose result is the same whichever holder
|
||||
does it: a build is, because what comes out is published by digest to the mesh's store.
|
||||
|
||||
**4. This is one pattern, not one role's.** Any module that declares a node-scoped seat with
|
||||
`accepts` gets decisions 1 and 2 with no further mechanism: the controller derives the queue and the
|
||||
worker, the holders pull, the module's manifest says what every holding machine must have. The
|
||||
build agent is the first; a module needing work done *somewhere on the mesh* — a scan, a
|
||||
conversion, a fetch — declares a seat of its own the same way
|
||||
([ADR 0126](0126-a-module-declares-its-own-seats.md)).
|
||||
|
||||
**5. Building is the first such role.** The build role is `node-build-agent`, scope node, with the
|
||||
same `build` ask and the same `started`, `built` and `log.<id>` events as before. Its holder is the
|
||||
`build-agent` module: the builder as it is — a container runtime, the artifact store and the package
|
||||
registry resolved as provisions, a workspace, the bus credential — assignable to every machine that
|
||||
has a container runtime. `mesh-build-machine` and the `builder` module are retired when the new
|
||||
holder is assigned where the old one was. The tiered plan ([ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md))
|
||||
is unchanged: a tier's asks go out together and are now worked together.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A tier of thirty-five images is built by as many machines as hold the seat and are online. A
|
||||
machine that is off builds nothing and blocks nothing.
|
||||
- Every holding machine fetches base images from the store and pushes what it builds; the store is
|
||||
reached as a provision, so this is what the provision was for. A machine with a slow link builds
|
||||
slowly, and takes fewer asks for it, which is the point of pulling.
|
||||
- A build's outcome carries which machine built it, so a build that fails on one machine and not
|
||||
another is a fact the record shows, not a mystery.
|
||||
- What got harder: a build's cache is per machine, so a cold machine pays the first pull of every
|
||||
base it has never seen; the artifact store is now asked by several machines at once, and the
|
||||
package registry likewise. Both are provisions and both are made for that.
|
||||
- ADR 0121's *"the mesh's single build machine"* and ADR 0162's *"built by whichever build machine
|
||||
is running — the only one there could be"* were true and are no longer; both records carry a note.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Two holders of one seat each take one of two asks, and a third ask waits for the first to be idle | the controller's test over the work queue against a real bus: two machines bound to one worker, three asks |
|
||||
| An ask is never delivered to a busy holder | the same test: the busy holder's ask count stays at one until it acknowledges |
|
||||
| A holder that dies mid-work leaves its ask for another | the same test, one holder closed mid-ask |
|
||||
| The asker names no machine | the controller's seat table: `node-build-agent` accepts `build` and the asking side publishes to the seat's accept subject, as the existing tests of `build` already require |
|
||||
| Live | `builds` shows a tier's builds `on` more than one machine within one plan; `seats` shows `node-build-agent` held on every machine with a container runtime — *held on all four machines and a build taken by a workstation's agent, 2026-10-03* |
|
||||
|
||||
## References
|
||||
|
||||
- [Design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 and §5 — the work queue and the queue
|
||||
group of holders this uses as drawn
|
||||
- [Design 18](../03-DESIGN/01-to-be/18-building-a-module.md) — building a module, amended for
|
||||
where a build runs
|
||||
- [ADR 0041](0041-events-are-a-relationship.md), [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md),
|
||||
[ADR 0126](0126-a-module-declares-its-own-seats.md), [ADR 0157](0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md),
|
||||
[ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
|
||||
- [Issue 175](../04-ISSUES/175-an-announcement-behind-a-long-build-comes-back/00-report.md) — why the
|
||||
worker had one in flight, and why pulling removes the cause rather than the symptom
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
topic: the tiers
|
||||
status: accepted
|
||||
date: 2026-10-03
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
supersedes-in-part:
|
||||
- 0066-public-routing-is-name-agnostic.md
|
||||
- 0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md
|
||||
---
|
||||
|
||||
# 191. The mesh's resolver holds only the mesh's own names; a public name resolves publicly
|
||||
|
||||
> **Progressive insight — 2026-10-03.** The first implementation told the mesh's names from public
|
||||
> ones by their spelling — a name ending in the mesh suffix — and this record said so: the Decision
|
||||
> read *"only names under its own suffix"*, and the roster check *"every name the roster carries ends
|
||||
> in the mesh suffix"*. The mesh needs no such test, nor any per-route name: domains are a node's. A
|
||||
> node has **one internal domain**, `<node>.internal`, and every route on it is a name under that domain
|
||||
> (ADR 0151), answered by one wildcard per node; a node has **one or more public domains**, which public
|
||||
> DNS answers. So the mesh's resolver holds the nodes' internal domains and nothing else, and the roster
|
||||
> carries the machines and no routed name. Both sentences now say that; what was decided — a public
|
||||
> name is never given a private answer — is unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
**[ADR 0066](0066-public-routing-is-name-agnostic.md) published every routed name into internal
|
||||
resolution, mesh-wide, at the address of the node that serves it.** The reason was an internal
|
||||
certificate authority in the lab: it validates by connecting to the name it certifies, and a routed
|
||||
public name that nothing inside the mesh resolved could not be certified.
|
||||
[ADR 0151](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md) kept it:
|
||||
*the roster publishes it as itself, once, at the serving node's address.*
|
||||
|
||||
**So every machine's resolver answered public names with private-network addresses.** On a
|
||||
production mesh on 2026-10-03, each machine's hosts region carried 47 lines of the form
|
||||
`<private address> <label>.<public domain>` — every public name of the control-node at its tunnel
|
||||
address, every public name of the home server at its own. For the machines themselves this is merely
|
||||
a detour: their traffic to a public name goes through the tunnel instead of the internet.
|
||||
|
||||
**For anything that is not a member it is an outage.** The home server's resolver also answers its
|
||||
LAN — a listen address added as a setting on 2026-10-02. A phone on that LAN asked for the mail
|
||||
server's public name, was given the control-node's tunnel address, and could not connect:
|
||||
*couldn't connect to host, port: 10.10.0.1:143*. Every public name of the mesh failed the same way for
|
||||
every non-member on that LAN — a phone, a television, a guest — while every check the mesh runs
|
||||
reported success, because every check runs from a member.
|
||||
|
||||
**And the reason for publishing them is gone.** ADR 0151 gave every route an internal name,
|
||||
`<label>.<serving node>.internal`, under the node's own name. It resolves inside the mesh without any
|
||||
entry of its own, the proxy serves it, and the internal authority certifies it — the proxy has two
|
||||
authorities since 2026-09-25: a public one for public names, the internal one for internal names.
|
||||
Measured the same day: `drive.<control-node>.internal` resolves to the control-node's tunnel address
|
||||
and answers 200 with a certificate that verifies against the internal root. Nothing the mesh runs
|
||||
needs a public name to resolve to a private address. The one consumer that did — an internal
|
||||
authority validating a public name — is the case the second authority removed.
|
||||
|
||||
The predecessor's resolver held exactly this and no more: an address per machine under `.internal`,
|
||||
and everything else forwarded to public resolvers.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Keep publishing public names; stop the resolver answering the LAN.** Fixes the phone and
|
||||
nothing else. The mesh would still hold a second, private answer for names the public DNS already
|
||||
answers — two answers for one name, which disagree by design and are correct in different places.
|
||||
And it forbids a reasonable setup: a home server's resolver serving its own LAN.
|
||||
|
||||
**2. Answer per source: private addresses to members, public ones to everyone else.** Split-horizon
|
||||
by client. It is what a resolver serving two audiences would need *if* the private answer were worth
|
||||
giving. It is not — option 3 shows nothing needs it — and it makes a name's address depend on who
|
||||
asks, which is the hardest kind of fault to see from a member.
|
||||
|
||||
**3. The mesh's resolver holds only the mesh's own domain.** Names under the mesh suffix — machines,
|
||||
and routes' internal names under them — resolve to private addresses. Every other name, including
|
||||
every public name the mesh serves, is forwarded and resolves publicly. Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**The mesh's resolver holds each node's internal domain and nothing else** — `<node>.internal` and
|
||||
everything under it, at that node's private address. A machine's name,
|
||||
and through it every `<label>.<node>.internal`, resolve to that machine's private address. **A public
|
||||
name is never given a private answer by the mesh**: it resolves through public DNS to the public
|
||||
address, from members and non-members alike.
|
||||
|
||||
This replaces ADR 0066's clause *"when the proxy is granted a name, the mesh publishes that name →
|
||||
the node that serves it into internal resolution, mesh-wide"*, and ADR 0151's *"the roster publishes
|
||||
it as itself, once, at the serving node's address."* Everything else in both stands: the label, the
|
||||
node's public domain, the composition, and the internal name under the serving node.
|
||||
|
||||
**Inside the mesh, a route is reached by its internal name.** A container or a validator that must
|
||||
reach a routed service inside the mesh uses `<label>.<node>.internal`; the internal authority
|
||||
certifies that name, and a public authority certifies the public one. A mesh with no public
|
||||
reachability — the lab — certifies its internal names and has no public names to resolve.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A resolver serving a LAN is safe.** What it adds to public resolution is the mesh's own domain,
|
||||
which no public resolver answers.
|
||||
- **A member reaches a public name over the internet, as anyone does.** A route the proxy restricts
|
||||
to the private network is reached by its internal name, never by its public one — a public name
|
||||
is, by this decision, public.
|
||||
- **The internal authority certifies internal names only.** It was the only consumer of a public
|
||||
name's private answer; the proxy's second authority already took that role away from it.
|
||||
- **Public names leave every machine's hosts region** on the first push after the change.
|
||||
Containers do not move with it: the roster is not part of a container's identity
|
||||
([ADR 0148](0148-the-meshs-names-are-resolved-not-copied-into-containers.md)).
|
||||
|
||||
**How each is checked:**
|
||||
|
||||
- **The roster:** the controller's tests assert that the roster names the machines and nothing
|
||||
else — a routed name in it, public or internal, fails the build.
|
||||
- **On a machine:** asking the machine's resolver for a public name the mesh serves returns the
|
||||
public address, and asking it for that route's internal name returns the private one. Asked from a
|
||||
non-member on a LAN the resolver answers, the first must hold as well.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0066 — public routing is name-agnostic](0066-public-routing-is-name-agnostic.md), whose
|
||||
propagation clause this replaces.
|
||||
- [ADR 0151 — a route's internal name is composed under the node that serves it](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md),
|
||||
which made the private answer unnecessary.
|
||||
- [Connectivity design §2 and §5](../03-DESIGN/01-to-be/08-connectivity.md), amended alongside this
|
||||
record.
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-03
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md
|
||||
---
|
||||
|
||||
# 192. A tools bundle declares what it is given, and the runtime hands it to that bundle alone
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) put one
|
||||
runtime on every node serving every module's tools from a bundle, and
|
||||
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
|
||||
said a module's own code is bundles and never an image. The two holders that moved first
|
||||
([to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4) needed nothing a
|
||||
bundle does not have: a fixed path, and root. Research
|
||||
[020](../01-RESEARCH/020-what-a-bundled-tool-is-given/00-overview.md) measured the rest before
|
||||
they move: thirty-three modules still run their tools as a container on the runtime's image, and
|
||||
thirty-one of them are handed, through the container's environment and mounts, things a bundle
|
||||
has no way to receive — the module's configuration file, its own secret as a file, the service's
|
||||
address with the port the mesh chose, a provision's address, a directory of grants. Every one of
|
||||
those is a file the mesh already places on the machine or a value the controller already composes
|
||||
for the container, per module per machine, from references the manifest writes: a placed
|
||||
directory, a chosen port. And the SDK's tool contributor is a function of an environment that the
|
||||
runtime calls without one, so every bundle reads the process's four words.
|
||||
|
||||
Without a rule, each of the thirty-one would answer the question its own way, and the runtime's
|
||||
process would be the one place where every module's paths meet.
|
||||
|
||||
> **Progressive insight — 2026-10-03.** The context above calls the thirty-one remaining containers
|
||||
> tool containers handed what a bundle cannot receive. Measured the same day while building this
|
||||
> record: nine of them run only tools; three run a main of their own; twenty import, beside their
|
||||
> tools, the module's own long-running code — event handlers that subscribe on the bus and
|
||||
> provisioners that act on grants — under the module's own bus identity, and some reach their
|
||||
> service by a container network name or need a package the image installed. That code is not a
|
||||
> tool and is not this record's to move: under
|
||||
> [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
|
||||
> §3 it is a `process` bundle, and how it is given its credential, its words and its reach is the
|
||||
> open question of [design 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4c.
|
||||
> Decision 4 applies to these containers' tools; the containers themselves go when their other
|
||||
> code has moved. The decision and its options stand.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The bundle declares its environment on its artifact, and the mesh composes it as a
|
||||
container's.** Chosen. The tools artifact gains `env`: names to values, the values written with
|
||||
the references the composer already resolves for a container — `${dir:…}`, `${port:…}` — and
|
||||
what was a mount target becomes the host path itself. The controller composes one environment
|
||||
per bundle per machine into the runtime's declaration. The runtime hands it to that bundle's
|
||||
contributor, or to the child it launches, and to nothing else. The tool code reads the names it
|
||||
read before.
|
||||
2. **The runtime derives it from the module's placed manifest** — a conventional word per
|
||||
directory and port, no new field. Rejected: a convention the thirty-one tools must be rewritten
|
||||
to, the runtime learning the composer's job, and a module that names its file one way and a
|
||||
module that names it another needing different words regardless.
|
||||
3. **The tool asks the controller over the bus.** Rejected: a tool that cannot start until the
|
||||
bus answers fails in the one case tools exist for, and a secret crossing the bus to reach a file
|
||||
already on the machine is a disclosure for nothing.
|
||||
4. **Leave each module to its own device.** Rejected by the measurement: thirty-one modules, one
|
||||
question.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A tools bundle says what it is given, on its artifact.** `build.artifacts[].env` names the
|
||||
words the bundle reads and their values. A value is a path or a constant, composed with the
|
||||
references a container's environment may use; **never a secret's content.** A secret reaches a
|
||||
tool the way it reaches a container: as a file the mesh places, whose path the environment names.
|
||||
A bundle that declares no `env` is given nothing beyond the runtime's own words, which is what the
|
||||
two holders that moved have.
|
||||
|
||||
**2. The mesh composes it, per bundle per machine, as it composes a container's.** The same
|
||||
references, resolved the same way, to the host's own paths. The composed environment travels in
|
||||
the node's declaration beside the bundle's archive; a change to it is a change to the bundle for
|
||||
the purpose of `restart-on`.
|
||||
|
||||
**3. The runtime hands each bundle its own environment, and nothing of another's.** A bundle
|
||||
imported into the runtime's process receives it as the argument its contributor is written to
|
||||
take; a bundle launched as a child ([ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md) §2)
|
||||
receives it as the child's environment, over the runtime's own words. The runtime's process
|
||||
environment is not where a module's words go, and a tool that reads the process's environment
|
||||
rather than the one it was handed finds the runtime's four words and no module's.
|
||||
|
||||
**4. The remaining tool containers move in one change** after this is built, each proven by its
|
||||
tools answering from the runtime, and the registration gate of
|
||||
[to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP2 then refuses the
|
||||
container shape for every module, as ADR 0188 already provides.
|
||||
|
||||
> **The mechanism changed — 2026-10-03, by ADR 0193.** Decision 3's imported path — the environment
|
||||
> handed to an imported bundle's contributor — has nothing left to do: every served bundle is
|
||||
> launched, and a launched bundle's environment is its own. The decision stands.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The manifest gains one field on one artifact kind; the composer gains one more thing to resolve
|
||||
with references it has; the runtime gains the hand-off and the separation. The thirty-one modules'
|
||||
tool code does not change, and their conversion is the move of a container's `env` with its
|
||||
mounts folded into host paths.
|
||||
- A tool's inputs become legible in the manifest where its container hid them in mounts: what a
|
||||
module's tools read is declared beside what the module writes.
|
||||
- What got harder: the runtime must keep thirty-one environments apart in one process, and a
|
||||
bundle's author must not reach for the process's environment. The separation is a rule the
|
||||
runtime's test holds, not a property of the language.
|
||||
- `MESH_BROKER_FILE` is not a bundle's to declare: the runtime speaks with the node's credential
|
||||
([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)), and
|
||||
a module's own bus credential went with its container.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A value in a bundle's `env` is a path or a constant, never a secret's content | the catalogue's manifest check refuses a `${secret:…}` reference in a bundle's `env`, naming this record |
|
||||
| The composer resolves a bundle's `env` as a container's | the controller's composition test: one module, one bundle with `${dir:…}` and `${port:…}` in its `env`, the declaration carrying the host paths and the chosen port |
|
||||
| Each bundle sees its own environment and no other's | the runtime's test: two bundles with different `env`, loaded in one runtime, each answering with its own words and none of the other's; the same for a launched bundle |
|
||||
| A change to a bundle's environment restarts the runtime | the composition test above, with `restart-on` naming the bundle |
|
||||
| Live | a module whose tools read a configuration file and a token file answers from the runtime on one machine with no container |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
|
||||
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
|
||||
- Research [020](../01-RESEARCH/020-what-a-bundled-tool-is-given/00-overview.md) — the measurement
|
||||
and the options
|
||||
- [to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) — where the work is listed
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-03
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md
|
||||
---
|
||||
|
||||
# 193. Every bundle the runtime serves is launched, and the runtime knows no language
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
|
||||
§2 made a served bundle a process that speaks MCP over stdio, and kept one exception: a TypeScript
|
||||
bundle may be imported into the runtime's own process, "a shortcut over the same contract". Every
|
||||
module's tools today take the shortcut, and it is where the day's defects came from:
|
||||
|
||||
- [Issue 209](../04-ISSUES/209-a-bundles-own-sdk-copy-registers-into-a-registry-the-runtime-never-reads/00-report.md):
|
||||
an imported bundle's own copy of the SDK registered into a registry the runtime never read; fixed
|
||||
by a resolve hook that redirects every bundle's SDK import to the runtime's copy.
|
||||
- [ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md):
|
||||
thirty modules' environments in one process had to be kept apart by an SDK change and runtime
|
||||
bookkeeping, where a process of its own has an environment of its own by construction.
|
||||
- One faulty module can block or crash every other module's tools on its node.
|
||||
|
||||
And the shortcut ties the runtime to Node.js: only a JavaScript runtime can import JavaScript. The
|
||||
operator's direction on 2026-10-03: *a module's tools are written in any language and the builder
|
||||
builds them; the runtime runs them all and announces them; it should be fully language agnostic,
|
||||
and node-tools can be rewritten in Go.*
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the shortcut.** Rejected: it is the cause of the three defects above, and it pins the
|
||||
runtime's language.
|
||||
2. **Launch every served bundle; the runtime knows how to start each language** (`node` for a
|
||||
`.js`, exec for a binary). Rejected: the runtime would hold a table of interpreters, and a
|
||||
runtime in Go would carry Node.js's knowledge for nothing.
|
||||
3. **Launch every served bundle, and the build makes each served entrypoint executable.** Chosen.
|
||||
A compiled language's binary is executable already; for an interpreted one the toolchain writes
|
||||
a launcher beside the entrypoint — for TypeScript, a file that imports the entrypoint and serves
|
||||
what it registered over stdio, using the bundle's own SDK. The runtime execs what it is given.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Every bundle the node's runtime serves is a child process speaking MCP over stdio.** The
|
||||
in-process shortcut of ADR 0188 §2 is withdrawn. Everything else ADR 0188 §2 says — `tools/list`,
|
||||
`tools/call`, `<seat>.<verb>` naming a seat's verb, the runtime serving each on the bus — stands.
|
||||
|
||||
**2. The runtime knows no language.** It is given an executable per served entrypoint and starts
|
||||
it, with that bundle's environment ([ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md))
|
||||
over its own words, and the module it serves it as. What makes an entrypoint executable is the
|
||||
toolchain's business: a binary is one; an interpreted language's toolchain writes a launcher.
|
||||
|
||||
**3. A launched bundle is told the module it serves as**, so that what it lists unprefixed is that
|
||||
module's own tools and a seat's verbs are always `<seat>.<verb>`, whichever it registered first.
|
||||
|
||||
**4. The runtime may be written in any language.** Nothing it does needs it to share a language
|
||||
with a bundle; the mesh's runtime moves to Go, against this contract, once the contract is proven
|
||||
in the runtime that exists.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A process per served module per node. On the busiest machine that is a score of small children
|
||||
where there was one process; a Go bundle costs a fraction of a Node.js one.
|
||||
- The SDK resolve hook (issue 209) and the per-registration environment hand-off (ADR 0192 §3,
|
||||
imported bundles) have nothing left to do and go; a launched bundle's environment is its own.
|
||||
- A module's TypeScript tool code does not change: it registers as before, and the generated
|
||||
launcher serves what it registered.
|
||||
- A bundle that crashes or hangs takes only its own tools down, and is started again on its next
|
||||
call, as ADR 0188 already provides for a launched bundle.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Every served bundle is launched | the runtime's tests: a TypeScript bundle and a bundle in a second language, both launched, both answering over a real bus; a non-executable entrypoint is refused by name |
|
||||
| The runtime knows no language | the runtime holds no interpreter: it execs the path it is given (code review; the Go runtime has no Node.js dependency at all) |
|
||||
| A TypeScript served entrypoint is executable | the builder's test: a TypeScript bundle's served entrypoint has a launcher beside it, mode 0755 |
|
||||
| A seat's verbs are named as the seat's whichever registers first | the SDK's test: a bundle registering its seat first and its own tools second lists `<seat>.<verb>` and its own tools unprefixed |
|
||||
| Live | the packet filter's and intrusion prevention's seat verbs and the moved tools answer from launched bundles on every machine |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
|
||||
[ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md),
|
||||
[ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md)
|
||||
- [to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4d
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
---
|
||||
topic: the tiers
|
||||
status: accepted
|
||||
date: 2026-10-03
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
supersedes-in-part:
|
||||
- 0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
|
||||
extends: 0191-the-meshs-resolver-holds-only-the-meshs-own-names.md
|
||||
---
|
||||
|
||||
# 194. The mesh has one resolver, and every node asks it for the mesh's names
|
||||
|
||||
> **Narrowed, not replaced — 2026-10-03.** How a node asks is decided again by
|
||||
> [ADR 0196](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md): every
|
||||
> node and container asks `mesh-resolver` first and a public resolver only when it is silent. There is
|
||||
> no `systemd-resolved` module and no runtime `dns` naming `mesh-resolver`, and step 2 of the migration
|
||||
> reads as 0196 states it. Option 2 below was rejected for a laptop with its tunnel down resolving
|
||||
> nothing; a public resolver listed second answers exactly then. The one resolver, its placement and
|
||||
> the retirement of every per-node copy stand.
|
||||
|
||||
## Context
|
||||
|
||||
**Every node runs its own resolver and holds its own copy of the mesh's names.** On the production
|
||||
mesh on 2026-10-03, each of the four nodes held `node-dns-resolver` with dnsmasq, fed on every push
|
||||
with a zones file (one wildcard per node) and a region of `/etc/hosts` (the machines), and pointed
|
||||
its own `/etc/resolv.conf` at itself. The controller computes the names once; four daemons then hold
|
||||
four copies, each read in its own way.
|
||||
|
||||
**Every resolution fault found that day was a copy disagreeing with the truth, not the truth being
|
||||
wrong:**
|
||||
|
||||
- **A copy read once.** dnsmasq reads `/etc/hosts` at start. After the controller stopped publishing
|
||||
public names ([ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)), every node's
|
||||
hosts file was right and every resolver still answered the mail server's public name with a
|
||||
tunnel address, until each was restarted.
|
||||
- **A copy beside other copies.** On the workstation, a name resolved to two addresses in rotation:
|
||||
the mesh's region gave the tunnel address, and two lines the operator had written before the mesh
|
||||
existed — one in `/etc/hosts`, one in a file the resolver also reads — gave the LAN address. A
|
||||
comment beside one of them said to delete it once the mesh took over; nothing made that happen.
|
||||
- **A copy that became somebody else's resolver.** The home server's resolver also answers its LAN
|
||||
(a listen address added 2026-10-02), and the LAN's router hands that address out as the only DNS
|
||||
server. Every phone and television on the LAN resolved through a mesh node's private copy, which is
|
||||
how ADR 0191's outage reached them.
|
||||
|
||||
**And the overlay already has one centre.** Every node has exactly one tunnel peer — the anchor —
|
||||
and routes the whole private range through it. Two nodes on the same LAN reach each other through
|
||||
the anchor. So a name under `.internal` is only ever useful while the anchor is reachable: a resolver
|
||||
anywhere else adds a copy without adding an answer anybody can use.
|
||||
|
||||
**What a node asks is already a separate role.** The connectivity design split *serving* (answers
|
||||
the names) from *asking* (decides what the machine asks), because systemd-resolved cannot answer a
|
||||
wildcard and can only route the mesh's suffix to something that can
|
||||
([connectivity §2](../03-DESIGN/01-to-be/08-connectivity.md)). ADR 0121 kept them as two seats,
|
||||
`node-dns-resolver` and `node-resolver-config`, both at node scope. No node runs systemd-resolved
|
||||
today; each writes `/etc/resolv.conf` as a plain file pointing at its own dnsmasq.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Keep a resolver on every node, and make the copies more careful.** Restart on every file it
|
||||
reads, own every file it reads, refuse to listen on a LAN. Each is a fix for one way a copy goes
|
||||
stale, and the next way is not on the list yet. It keeps four answers to one question.
|
||||
|
||||
**2. One resolver for the mesh, and every node sends it every query.** The simplest asking side —
|
||||
`resolv.conf` names the mesh's resolver and nothing else. Rejected: public resolution then depends on
|
||||
the tunnel. A laptop whose tunnel is down could resolve nothing at all, and a public name would take
|
||||
a detour through the anchor for no reason ADR 0191 left standing.
|
||||
|
||||
**3. One resolver for the mesh's names; each node asks it for those only.** The mesh's resolver holds
|
||||
every node's internal domain. Each node's asking role routes the mesh's suffix to it and every other
|
||||
name to public resolvers. Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**The mesh has one resolver.** It is a module holding a new mesh-scoped seat, **`mesh-resolver`**
|
||||
(capacity one). It holds each node's internal domain — `<node>.internal` and everything under it, at
|
||||
that node's private address ([ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md))
|
||||
— and listens on the private network only. It is placed on the node every tunnel converges on, so
|
||||
that it shares the overlay's single point rather than adding one. Which daemon fills the seat stays
|
||||
the module's business, as the connectivity design says.
|
||||
|
||||
**Every node asks it for the mesh's names and nothing else.** `node-resolver-config` routes the
|
||||
mesh's suffix to `mesh-resolver` and leaves every other name with public resolvers.
|
||||
|
||||
**Why a stub on every node.** `resolv.conf` cannot route by domain: the C library asks the servers it
|
||||
lists in order, for every name, and moves to the next only when one does not answer — an NXDOMAIN
|
||||
from the first is final. Listing `mesh-resolver` first sends every public name through the tunnel
|
||||
(option 2); listing a public resolver first means `.internal` is never asked of the mesh. Something on
|
||||
the node has to look at the name before choosing a server, and that is a stub resolver. Keeping
|
||||
dnsmasq for it would keep a daemon that reads hosts files and can be told to answer a LAN — the two
|
||||
ways copies went wrong. systemd-resolved holds no names of its own, routes by domain natively (a
|
||||
routing domain `~<suffix>` on the server that answers it), and is part of systemd, already installed
|
||||
on every node and enabled on none.
|
||||
|
||||
**So the asking side is a `systemd-resolved` module**, claiming `node-resolver-config` — the same claim
|
||||
as the `resolv-conf` module it replaces, so the mesh refuses both on one node. It enables the service,
|
||||
writes its configuration (the mesh resolver for the suffix, public resolvers for everything else), and
|
||||
writes `/etc/resolv.conf` as a file naming the stub — a file the module owns, not a link to one.
|
||||
|
||||
**A container asks the mesh's resolver directly.** The container runtime cannot use a loopback stub
|
||||
and drops its routing domains, so the runtime's `dns` names `mesh-resolver`, which forwards public
|
||||
names for the containers that ask it. This is the one place a public name passes through the mesh,
|
||||
and it is stated rather than hidden.
|
||||
|
||||
**`node-dns-resolver` is retired**, and with it every per-node copy: the zones file, the mesh's region
|
||||
of `/etc/hosts` (the floor connectivity §2 already planned to remove), and the daemon on every node
|
||||
but the one holding `mesh-resolver`. This narrows ADR 0121's *"the-dns-port → node-dns-resolver"*:
|
||||
the serving role keeps its distinction from the asking role and moves to mesh scope, as ADR 0121 did
|
||||
for the private network.
|
||||
|
||||
**A LAN's resolver is not the mesh's.** No device that is not a member can reach a private address,
|
||||
so no member's resolver answers a LAN on the mesh's behalf. A router that hands out a node's address
|
||||
as a LAN's DNS server is pointed elsewhere before that node stops answering.
|
||||
|
||||
**The order is fixed, because every step before the last leaves a working resolver:**
|
||||
|
||||
1. `mesh-resolver` is assigned and answers on the private network.
|
||||
2. Each node's `node-resolver-config` moves from `resolv-conf` to `systemd-resolved`, and the container
|
||||
runtime's `dns` to `mesh-resolver`.
|
||||
3. A LAN whose router points at a node's resolver is pointed at its router or a public resolver.
|
||||
4. `node-dns-resolver` is unassigned from every node, and the hosts region is withdrawn.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **One answer per name.** A name is wrong in one place or right everywhere; no node can hold a copy
|
||||
that disagrees, and no operator file on a node is read by the mesh's resolver.
|
||||
- **The anchor down means no `.internal` names** — which it already meant for `.internal` traffic,
|
||||
since every tunnel goes through it. Public resolution on every node is unaffected.
|
||||
- **A container's public resolution depends on the mesh's resolver.** Accepted, and named in the
|
||||
decision; a container that must resolve public names with the tunnel down is the case it costs.
|
||||
- **Every node runs systemd-resolved**, through the `systemd-resolved` module. It is installed
|
||||
everywhere already and enabled nowhere; the mesh still ships no resolver of its own.
|
||||
- **The runtime's `dns` changes once per node**, which the runtime reads only at start. With
|
||||
`live-restore` already on, that restart keeps every container running.
|
||||
- **A LAN loses a resolver it had borrowed.** The router change is an explicit step, done through
|
||||
the module that manages the router, before the node's resolver goes.
|
||||
|
||||
**How each is checked:**
|
||||
|
||||
- **One holder:** the seat has capacity one, so a second assignment is refused by the controller.
|
||||
- **Asking:** on each node, `resolvectl` shows the tunnel's link with `mesh-resolver` and the suffix as
|
||||
its routing domain; a name under `.internal` is answered by it, and a public name is answered
|
||||
without it (its query log shows no public name from a node).
|
||||
- **No copies:** no node but the holder answers DNS on a private or LAN address — every other node's
|
||||
port 53 is systemd-resolved's loopback stub and nothing else — and no node's `/etc/hosts` carries a
|
||||
mesh region.
|
||||
- **A LAN:** the router's DHCP DNS option names no node's address.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md) — what the mesh's resolver
|
||||
holds; this record decides where it runs and how nodes reach it.
|
||||
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — the two
|
||||
resolver seats, and the private network's move to mesh scope this mirrors.
|
||||
- [Connectivity §2](../03-DESIGN/01-to-be/08-connectivity.md) — serving and asking as two roles;
|
||||
amended alongside this record.
|
||||
- [The seats](../03-DESIGN/01-to-be/26-the-seats.md) — the seat table, amended alongside.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-03
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
|
||||
---
|
||||
|
||||
# 195. The mesh's tools are found by address, not announced whole
|
||||
|
||||
## Context
|
||||
|
||||
The console answers MCP on a machine's loopback ([ADR 0152](0152-the-operators-surface-is-a-module-the-console.md),
|
||||
[to-be 34](../03-DESIGN/01-to-be/34-the-console.md)) and announces, at a session's start, every tool
|
||||
the mesh can say it has: 228 on 2026-10-03, 110 KB, taken once. Three things are wrong with that,
|
||||
measured in research [021](../01-RESEARCH/021-finding-a-tool-in-the-mesh/00-overview.md):
|
||||
|
||||
- **Size.** Only one client's habit of deferring long lists keeps them out of the model's context.
|
||||
- **Ambiguity.** A module on two machines is listed once, `node` optional, *whichever answers* — for
|
||||
postgres and mssql, whose instances hold different data, a call that names no machine asks an
|
||||
arbitrary one.
|
||||
- **Staleness.** A tool that arrives after the session started is not listed until it reconnects.
|
||||
|
||||
The mesh already has the structure a caller needs: seats held once for the mesh, seats held once per
|
||||
machine ([ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)), and
|
||||
modules assigned to machines, each assignment issued its own subjects
|
||||
([ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)).
|
||||
The operator's direction: *tools are discoverable, in layers, and asking novox's postgres is not asking
|
||||
ace's.*
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. Keep the flat list and rely on the client. Rejected: the ambiguity and the staleness stay, and it
|
||||
is one client's behaviour.
|
||||
2. One tool per assignment, the machine in the name. Rejected: the list multiplies, and an address in
|
||||
a tool's name meets the API's limit — letters, digits, `_` and `-`, at most 64 characters.
|
||||
3. **A fixed handful of tools that walk the mesh's structure, the address an argument.** Chosen.
|
||||
4. MCP resources or prompts. Rejected: unevenly supported, and an agent acts through tools.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Everything the mesh answers has one address, by the layer it lives in:**
|
||||
|
||||
| layer | address | answered by |
|
||||
|---|---|---|
|
||||
| a seat held once for the mesh | `<seat>.<verb>` | that seat's holder |
|
||||
| a seat held once per machine | `<node>/<seat>.<verb>` | that machine's holder |
|
||||
| a module assigned to a machine | `<node>/<module>.<tool>` | that assignment |
|
||||
| a module whose instances are interchangeable (ADR 0160) | `<module>.<tool>` as well | any of them |
|
||||
|
||||
**A call to a module that is not interchangeable names its machine, or is refused** naming the machines
|
||||
it runs on. "Whichever answers" is no longer an answer for state a machine holds.
|
||||
|
||||
**2. The console announces a fixed set of tools, not the catalogue:**
|
||||
|
||||
- **`mesh_overview`** — the mesh's seats with their verbs, and its machines;
|
||||
- **`mesh_machine`** — one machine: the node seats it holds and the modules assigned to it, each with
|
||||
its tools by name;
|
||||
- **`mesh_search`** — words in, matching addresses out with one line each, across every layer;
|
||||
- **`mesh_describe`** — one address in, its description and argument schema out;
|
||||
- **`mesh_call`** — an address and its arguments in, the answer out, with the machine that gave it.
|
||||
|
||||
Each is answered from the mesh when it is asked, so a tool that arrived a minute ago is found without
|
||||
the client reconnecting. The names are the API's kind of name; addresses never have to be.
|
||||
|
||||
**3. The flat catalogue stays reachable, not announced:** the `mesh` client and a console setting can
|
||||
still list it whole, for a person reading it or a client that wants it. An agent pointed at the console
|
||||
sees the five.
|
||||
|
||||
> **The mechanism changed — 2026-10-03, by ADR 0197.** Where the console learns what exists: not
|
||||
> from the catalogue's roster and the controller's printed lists, but from every runtime announcing
|
||||
> itself on the bus in the NATS services protocol, checked against the controller's records read as
|
||||
> JSON. The addresses and the five tools stand.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An agent spends a call or two finding a tool it does not know, and none on one it does; the context
|
||||
no longer carries 110 KB it mostly never uses.
|
||||
- The ambiguity is closed by the address, not by a description asking the agent to remember `node`.
|
||||
- What got harder: an agent that once saw a tool's schema up front now asks for it. `mesh_describe` and
|
||||
`mesh_search` answering with the schema of a close match keep that to one call.
|
||||
- The discovery verbs are the console's; the mesh's own records — seats, machines, assignments — are
|
||||
the controller's, and the console asks it rather than keeping a copy.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The console announces five tools | the console's test: `tools/list` answers exactly the five |
|
||||
| An address resolves to one subject per layer | the console's tests: a mesh seat, a node seat, an assignment, an interchangeable module, each called by address over a real bus |
|
||||
| A non-interchangeable module without a machine is refused, naming its machines | the same tests |
|
||||
| A tool that arrives after the session started is found | a test registering a module after the console's first answer and finding it by `mesh_search` |
|
||||
| Live | from a fresh session, *which databases does novox's postgres hold* is answered by novox's postgres, found through the five |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md),
|
||||
[ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md),
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
||||
- Research [021](../01-RESEARCH/021-finding-a-tool-in-the-mesh/00-overview.md)
|
||||
- [to-be 34](../03-DESIGN/01-to-be/34-the-console.md)
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
---
|
||||
topic: the tiers
|
||||
status: accepted
|
||||
date: 2026-10-03
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
supersedes-in-part:
|
||||
- 0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
|
||||
---
|
||||
|
||||
# 196. A node asks the mesh's resolver first, and a public one only when it is silent
|
||||
|
||||
## Context
|
||||
|
||||
**[ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md) gave the
|
||||
mesh one resolver and had each node ask it for the mesh's names only.** Because `resolv.conf` cannot
|
||||
route by domain, that needed a stub on every node — a `systemd-resolved` module — and a separate
|
||||
`dns` for the container runtime, which cannot use a loopback stub. It rejected the simpler shape,
|
||||
every node sending every query to the mesh's resolver, on the grounds that *"a laptop whose tunnel is
|
||||
down could resolve nothing at all."*
|
||||
|
||||
**That is true only of a `resolv.conf` naming the mesh's resolver alone.** The C library asks the
|
||||
servers it lists in order and moves to the next when one does not answer within its timeout. A public
|
||||
resolver listed second is asked exactly when the mesh's is unreachable — the anchor down, the tunnel
|
||||
down, a laptop behind a captive portal that has not let the tunnel up — and never otherwise. An answer
|
||||
from the first, including "no such name", is final, so `.internal` is never asked of a public resolver
|
||||
while the mesh's answers.
|
||||
|
||||
**And the container runtime copies a machine's resolvers into its containers when they are not
|
||||
loopback addresses.** With the mesh's resolver and a public one listed, every container gets both, as
|
||||
they are, with nothing configured for the runtime.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Keep ADR 0194's stub.** Public names never touch the mesh, and a node with the anchor down
|
||||
resolves public names at full speed. It costs a module and a running service on every node, a second
|
||||
configuration for containers, and the one asymmetry ADR 0194 had to state — containers' public names
|
||||
through the mesh, nodes' not.
|
||||
|
||||
**2. Every node asks the mesh's resolver for everything, with a public resolver as the silent
|
||||
fallback.** One server answers every node and every container; nothing on a node routes, holds names,
|
||||
or runs. Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A node's `/etc/resolv.conf` names the mesh's resolver first and a public resolver second, with a
|
||||
short timeout and a single attempt.** It is written by the module holding `node-resolver-config` — the
|
||||
existing `resolv-conf` — which now names `mesh-resolver`'s address instead of the machine's own. The
|
||||
mesh's resolver answers the mesh's names from what it holds and forwards every other name, giving the
|
||||
public answer ([ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md) is unchanged: no
|
||||
public name gets a private answer).
|
||||
|
||||
**Containers take the same two resolvers from their machine.** The container runtime's own `dns`
|
||||
setting is not written; the runtime copies the machine's non-loopback resolvers into every container.
|
||||
|
||||
**This replaces, from ADR 0194:** the asking side as a stub (*"So the asking side is a
|
||||
`systemd-resolved` module"*), the container runtime's `dns` naming `mesh-resolver`, and step 2 of the
|
||||
migration as written. There is no `systemd-resolved` module. Everything else in ADR 0194 stands — one
|
||||
`mesh-resolver`, on the node every tunnel converges on, holding each node's internal domain, the
|
||||
retirement of `node-dns-resolver` and every per-node copy, and a LAN's resolver not being the mesh's.
|
||||
|
||||
**The migration, as it now reads:**
|
||||
|
||||
1. `mesh-resolver` is assigned and answers on the private network.
|
||||
2. Each node's `resolv-conf` names `mesh-resolver` first and a public resolver second.
|
||||
3. A LAN whose router points at a node's resolver is pointed at its router.
|
||||
4. `node-dns-resolver` is unassigned from every node, and the hosts region is withdrawn.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Every name a node or container asks goes through the anchor while it is up.** A public lookup
|
||||
takes a few milliseconds longer than asking a public resolver directly, and the mesh's resolver sees
|
||||
every name its nodes look up. It is the operator's own server.
|
||||
- **With the anchor unreachable, each lookup waits out one timeout, then resolves publicly.**
|
||||
`.internal` names fail then — as `.internal` traffic does, every tunnel going through the anchor.
|
||||
- **A LAN is unaffected by this choice.** Devices that are not members never read a node's
|
||||
`resolv.conf`; they get their resolver from their router, which step 3 points at itself.
|
||||
- **Nothing new runs on a node.** No stub, no module, no per-node configuration for containers.
|
||||
- **The runtime's `dns` key goes with `node-dns-resolver`.** The dnsmasq module wrote it into the
|
||||
runtime's configuration; unassigning that module in step 4 withdraws it, and the runtime reads the
|
||||
change only when it next starts — with `live-restore` on, that restart keeps every container
|
||||
running.
|
||||
|
||||
**How each is checked:**
|
||||
|
||||
- **Order:** each node's `/etc/resolv.conf` lists `mesh-resolver`'s private address first and a public
|
||||
resolver second, and nothing else.
|
||||
- **Fallback:** with `mesh-resolver` unreachable from a node, a public name still resolves there, after
|
||||
the timeout.
|
||||
- **Containers:** a container started on a node lists the same two resolvers.
|
||||
- **A LAN:** the router's DHCP DNS option names the router, not a node.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md) — the one
|
||||
resolver; this record replaces how nodes and containers ask it.
|
||||
- [ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md) — what the resolver holds.
|
||||
- [Connectivity §2](../03-DESIGN/01-to-be/08-connectivity.md), amended alongside this record.
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-03
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md
|
||||
---
|
||||
|
||||
# 197. Every tool announces itself on the bus, in the NATS services protocol
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0195](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md) gave every tool an
|
||||
address and the console five tools to find them. Where the console learns what exists, it inherited
|
||||
from [to-be 34](../03-DESIGN/01-to-be/34-the-console.md) §3: ask the catalogue for its roster, ask
|
||||
each module on the roster for its `tools`, and read the machines and assignments from the
|
||||
controller's printed `node list` and `module list`. Built that way on 2026-10-03, it worked and showed
|
||||
what is wrong with it:
|
||||
|
||||
- **It asks what should exist and infers what does.** The roster holds every module the catalogue
|
||||
ever registered; 47 of them were reported "not answering" on 2026-10-03, most with no tools at all
|
||||
and several retired.
|
||||
- **It parses prose.** Two of the controller's answers are text for a person, and a reworded column
|
||||
breaks discovery.
|
||||
|
||||
The bus already knows what answers. NATS has a services protocol for exactly this: a service answers
|
||||
`$SRV.PING` and `$SRV.INFO` — every instance, on one request — with its name, its instance, and every
|
||||
endpoint's subject and metadata, in a published format the NATS tools read. The operator's direction:
|
||||
*every tool announces itself; the mesh has the full picture, so nothing should be inferred or parsed.*
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep asking the roster, and give the controller JSON answers.** Fixes the parsing, keeps the
|
||||
inference.
|
||||
2. **Re-serve every tool through a NATS services library.** The announcement for free, but every
|
||||
runtime's serving path rewritten around a library, in two languages, for no change in behaviour.
|
||||
3. **Every runtime answers the services protocol's discovery subjects with what it serves; serving
|
||||
is unchanged.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. What answers announces itself.** Every runtime that serves tools — each machine's tool runtime,
|
||||
the per-module runtimes still in containers, and the controller for the seat it holds — answers
|
||||
`$SRV.PING` and `$SRV.INFO` in the NATS services format: one service per module or seat it serves,
|
||||
named for it, its instance the machine; one endpoint per tool, its subject and queue exactly as
|
||||
served, its metadata the tool's description, argument schema, the machine, the seat and scope where
|
||||
it is a seat's verb, and whether the module's instances are interchangeable.
|
||||
|
||||
**2. The console finds what exists by asking the bus,** one `$SRV.INFO` request, every answer
|
||||
gathered for a short window. What it announces through ADR 0195's five tools is what answered.
|
||||
|
||||
**3. What should exist is the mesh's records, read as data.** The controller answers its machines and
|
||||
modules as JSON, and says which modules declare tools; the console names as *not answering* only an
|
||||
assignment that declares tools and did not announce them. A module with no tools is never listed.
|
||||
|
||||
**4. The grants say so.** Every principal that serves tools may subscribe the services discovery
|
||||
subjects for what it serves; the console's and every runtime's account may publish the discovery
|
||||
request. Replies travel to the asker's own inbox as every reply does.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The standard `nats micro list` and `nats micro info` show the mesh's tools, live, to anybody holding
|
||||
a credential — the bus's own view, not the mesh's description of it.
|
||||
- Discovery costs one request and a gathering window, not one request per roster entry.
|
||||
- What got harder: three runtimes must answer the same format the same way — the Go tool runtime, the
|
||||
TypeScript runtime the containers still run, and the controller. The format is NATS's, so a test
|
||||
reads all three with the NATS services client and nothing of the mesh's.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A runtime announces exactly what it serves | each runtime's test: `$SRV.INFO` answered with one service per served module or seat, its endpoints' subjects the subjects served |
|
||||
| The format is NATS's | the same tests read the answer with the NATS services client's own types |
|
||||
| A module with no tools is never listed; an assignment with tools that did not answer is | the console's test, against controller records with both |
|
||||
| Nothing is parsed from prose | the console reads only JSON answers (code review; the text parsers are deleted) |
|
||||
| Live | `nats micro list` against the mesh's bus lists every machine's tool runtime and the controller |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0195](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md),
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md),
|
||||
[ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
|
||||
- [to-be 34](../03-DESIGN/01-to-be/34-the-console.md)
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-03
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md
|
||||
---
|
||||
|
||||
# 198. A module's long-running code is launched by the node's runtime, and reaches the bus through it
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0193](0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md) made
|
||||
every tools bundle a child the node's runtime launches, speaking MCP over stdio, and gave the channel
|
||||
one bus verb: a tool's emit, published by the runtime as the module. Twenty-three modules still run the
|
||||
rest of their own code — event handlers, provisioners, a preparation step, three mains — in a container
|
||||
on the runtime's image, because that code needs what a container gave it: a bus connection that can
|
||||
*subscribe*, and its module's words. Research [022](../01-RESEARCH/022-where-a-modules-long-running-code-runs/00-overview.md)
|
||||
measured what it uses. [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 never an image.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The runtime launches it and is its bus.** Chosen.
|
||||
2. A process per module with its own bus client and credential. Rejected for the reasons ADR 0188
|
||||
rejected it for tools: a transport in every language's SDK, a credential per module on disk, and a
|
||||
bus change rebuilding every module.
|
||||
3. Keep the containers for it. Rejected: ADR 0188's rule stays broken for most of the catalogue.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A module's long-running code is a bundle the node's runtime launches and supervises,** exactly as
|
||||
its tools are: an executable entrypoint, given the runtime's words and its module's
|
||||
([ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md)),
|
||||
started at the runtime's start and again when it exits. A bundle may serve tools, run long, or both.
|
||||
|
||||
**2. The runtime is its bus.** The stdio channel carries, beside MCP, the mesh's verbs a module's code
|
||||
uses: `mesh/publish` (ADR 0193), **`mesh/subscribe`** — the runtime binds that module's durable
|
||||
consumer, as the module's own runtime did, and delivers each event to the child as a `mesh/event`
|
||||
request, acknowledging it on the bus only when the child has answered — and **`mesh/ask`**, a tool
|
||||
call made on the module's behalf. The runtime's account is granted what each module it carries
|
||||
consumes, and the consumer keeps the module's name, so no event is lost or replayed in the move.
|
||||
|
||||
**3. A preparation step is a run-once process the host runs before the runtime starts the module,**
|
||||
with its module's words and no bus — what it already was.
|
||||
|
||||
**4. What a container reached by its network is reached on the machine.** A service by its published
|
||||
port (`${port:…}`) on loopback; a backend's command-line client as a package of the machine's system,
|
||||
or, where the system has none, the backend's own driver inside the bundle.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The per-module containers go, and with them the runtime image as a way module code runs; ADR 0188's
|
||||
registration rule can then refuse a module's own image without exception.
|
||||
- One bus connection per machine carries every module's events; a module's handler is a function of
|
||||
the events it is handed, in any language, with no bus client of its own.
|
||||
- What got harder: the runtime holds every carried module's consumer and must not acknowledge an event
|
||||
before the child has handled it — a child that dies mid-event leaves it unacknowledged, and it is
|
||||
delivered again. The runtime's grants widen to what its modules consume.
|
||||
- Two modules need code before they can move: their backends' clients exist on no machine's system,
|
||||
so they talk to the backend through a driver instead.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A subscribed event reaches the child and is acknowledged only after it answered | the runtime's test over a real bus: a child that answers is acknowledged once; one that dies mid-event is delivered again |
|
||||
| The module's consumer keeps its name | the composer's test: the durable consumer the runtime binds is the one the module's own runtime bound |
|
||||
| No module's own code is an image | the catalogue's registration check, without exception, once the last container has moved |
|
||||
| Live | every moved module's provisioner and handlers act, on their machines, from the node's runtime; `docker ps` shows no runtime-image container on any machine |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md),
|
||||
[ADR 0192](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md),
|
||||
[ADR 0193](0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)
|
||||
- Research [022](../01-RESEARCH/022-where-a-modules-long-running-code-runs/00-overview.md)
|
||||
- [to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4c
|
||||
+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
|
||||
@@ -179,6 +179,21 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0163** — [Taking a module over is a comparison: what it compares, what it refuses, and what it carries](0163-taking-a-module-over-is-a-comparison.md)
|
||||
- **0167** — [A membership carries what its module receives, and who the mesh is](0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md)
|
||||
- **0168** — [A converged machine is filtered by the mesh alone, and the host says what else refuses](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md)
|
||||
- **0169** — [A machine joins through the tunnel, and the bus is never public](0169-a-machine-joins-through-the-tunnel-and-the-bus-is-never-public.md)
|
||||
- **0170** — [The firewall seat serves its verbs, and a foreign rule set is removed through one of them](0170-the-firewall-seat-serves-its-verbs.md)
|
||||
- **0172** — [The lab is a module, and runs a bed when the mesh asks](0172-the-lab-is-a-module-and-runs-a-bed-when-the-mesh-asks.md)
|
||||
- **0179** — [The intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail](0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md)
|
||||
- **0180** — [The found front end is uninstalled once a machine is converged](0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md)
|
||||
- **0184** — [A service the mesh asked to run is still running a moment later](0184-a-service-the-mesh-asked-to-run-is-still-running-a-moment-later.md)
|
||||
- **0185** — [A control plane behind its seat's row serves what it can](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md)
|
||||
- **0186** — [A ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang](0186-a-ban-list-never-holds-a-neighbour.md)
|
||||
- **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
|
||||
|
||||
@@ -212,6 +227,10 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0126** — [A module declares its own seats; the mesh reserves its own](0126-a-module-declares-its-own-seats.md)
|
||||
- **0148** — [The mesh's names are resolved, not copied into every container](0148-the-meshs-names-are-resolved-not-copied-into-containers.md)
|
||||
- **0151** — [A route's internal name is composed under the node that serves it](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md)
|
||||
- **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
|
||||
|
||||
@@ -269,6 +288,31 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0150** — [A module's own code runs as supervised processes under the module's one account](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)
|
||||
- **0152** — [The operator's surface is a module the mesh assigns: the console](0152-the-operators-surface-is-a-module-the-console.md)
|
||||
- **0155** — [A definition names no installation: how that is checked, and the three ways a value that did gets out](0155-a-definition-names-no-installation-and-how-that-is-checked.md)
|
||||
- **0164** — [A setting is declared with its default, its meaning and what changing it costs](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md) *(proposed)*
|
||||
- **0165** — [`container-runtime` is what a machine can run; that a runtime is running is its holder's health](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md) *(proposed)*
|
||||
- **0166** — [The container runtime is a node seat, and the host creates containers through its holder](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md) *(proposed)*
|
||||
- **0173** — [The operator's machine is the mesh's, and a module is whatever it declares](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md)
|
||||
- **0175** — [One tool runtime per node serves every module's tools, on the host side](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)
|
||||
- **0176** — [The login shell is a node seat held by one shell module, and `execute` is its contract](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)
|
||||
- **0177** — [A unit may be user-scoped, and the service manager is a node seat whose holder answers for the units](0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)
|
||||
- **0181** — [The operator account is a node fact, and a home is a placement root](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)
|
||||
- **0182** — [Inside a home, the mesh owns the directory and the files it places, writes into the tool's own files, and holds everything else as found](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
|
||||
- **0183** — [The Anthropic licence manager is a module holding a seat; it hands each node's agent its token over the bus, sealed; the controller and the host have no part](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
|
||||
- **0188** — [A module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)
|
||||
- **0192** — [A tools bundle declares what it is given, and the runtime hands it to that bundle alone](0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md)
|
||||
- **0193** — [Every bundle the runtime serves is launched, and the runtime knows no language](0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)
|
||||
- **0195** — [The mesh's tools are found by address, not announced whole](0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)
|
||||
- **0197** — [Every tool announces itself on the bus, in the NATS services protocol](0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)
|
||||
- **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
|
||||
|
||||
@@ -290,6 +334,8 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0107** — [Persistent data is a directory bind, never a named volume](0107-persistent-data-is-a-directory-bind-never-a-named-volume.md)
|
||||
- **0111** — [A build source is on the mesh's git seat, or it is an external repository](0111-a-build-source-is-on-the-git-seat-or-external.md)
|
||||
- **0149** — [The live mesh is the test bed](0149-the-live-mesh-is-the-test-bed.md)
|
||||
- **0174** — [A node varies a module through settings and kept regions, never through an edit](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)
|
||||
- **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
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [mesh-lab]
|
||||
updated: 2026-09-11
|
||||
code: [mesh-lab, mesh-catalog modules/lab]
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0172-the-lab-is-a-module-and-runs-a-bed-when-the-mesh-asks.md
|
||||
- 02-DECISIONS/0016-the-lab.md
|
||||
- 02-DECISIONS/0010-delivery.md
|
||||
---
|
||||
@@ -119,7 +120,25 @@ In order, on a machine with nothing:
|
||||
|
||||
6. **Verification**, as above, before anything is raised.
|
||||
|
||||
## Open
|
||||
## The lab answers the mesh
|
||||
|
||||
*2026-10-02* ([ADR 0172](../../02-DECISIONS/0172-the-lab-is-a-module-and-runs-a-bed-when-the-mesh-asks.md)).
|
||||
Once installed, the lab is also a module: `lab`, assigned to the machine that passed `check`. Its
|
||||
tools run there and nowhere else:
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `lab_check` | the lab's `check`, on this machine |
|
||||
| `lab_run` | fresh checkouts of the named branches from the forge, side by side, then the suite on the named beds; answers with an id |
|
||||
| `lab_status` | where a run is, and how it ended: the commits it tested, passed and failed |
|
||||
| `lab_log` | the run's output so far |
|
||||
| `lab_stop` | ends a run |
|
||||
|
||||
The runtime is a container holding the toolchain the suite builds with. It reaches the
|
||||
virtualisation daemon and the container runtime through their sockets on the machine, so what it
|
||||
raises is what a hand run raises. The prerequisites above stay installed by hand. The module uses
|
||||
them, and never installs them.
|
||||
|
||||
|
||||
- **Whether the lab's bootstrap may install packages at all**, given that the mesh's rules
|
||||
forbid installing by hand. The resolution is probably that the lab's bootstrap *is* the
|
||||
|
||||
@@ -4,6 +4,7 @@ status: in-progress
|
||||
code: [mesh-host]
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0184-a-service-the-mesh-asked-to-run-is-still-running-a-moment-later.md
|
||||
- 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
|
||||
- 02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md
|
||||
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
|
||||
@@ -498,3 +499,17 @@ run and reported; the exit follows an in-flight apply rather than interrupting i
|
||||
not start is rolled back once and the second failure halts; a completed reconcile retires what is older
|
||||
than the predecessor and never the predecessor; and the newest of two delivered versions is the one
|
||||
that runs.
|
||||
|
||||
## A service is still running a moment later, 2026-10-02
|
||||
|
||||
[ADR 0184](../../02-DECISIONS/0184-a-service-the-mesh-asked-to-run-is-still-running-a-moment-later.md).
|
||||
The host has always read a unit back after acting on it, because a service manager accepting a
|
||||
command says the transaction was accepted and nothing about the process. The read raced the failure:
|
||||
a daemon that refuses the configuration the mesh just wrote exits a fraction of a second after the
|
||||
manager returns, and one look sees it alive. So the host looks twice, with a pause between, and a
|
||||
unit that was running and is not any more fails its resource by name. A unit still coming up reads
|
||||
as running at both looks and is accepted; a service asked to stop is not waited on.
|
||||
|
||||
No command for this reaches a machine. A module declaring *how to test my configuration* was weighed
|
||||
and refused: the link carries no actions, and a verification command is one. The host is checking
|
||||
the state it was told to establish, which is what it is for. *How it is checked:* ADR 0184's table.
|
||||
|
||||
@@ -5,10 +5,23 @@ code:
|
||||
- mesh-controller internal/catalogue/filtering.go
|
||||
- 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-02
|
||||
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
|
||||
- 02-DECISIONS/0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md
|
||||
- 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md
|
||||
- 02-DECISIONS/0169-a-machine-joins-through-the-tunnel-and-the-bus-is-never-public.md
|
||||
- 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
|
||||
- 02-DECISIONS/0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md
|
||||
- 02-DECISIONS/0148-the-meshs-names-are-resolved-not-copied-into-containers.md
|
||||
@@ -173,6 +186,28 @@ the broker's node must be dialable by every node, at a stable address, and so mu
|
||||
reachable; on one network it does not. A mesh whose nodes are all behind NAT cannot be raised, and
|
||||
a broker node whose address moves invalidates every token issued for it.
|
||||
|
||||
*2026-10-02.* **The order changes at step 1: the tunnel comes first, from the token**
|
||||
([ADR 0169](../../02-DECISIONS/0169-a-machine-joins-through-the-tunnel-and-the-bus-is-never-public.md)).
|
||||
The circularity above is real, and it is broken differently. The overlay is configured by the mesh,
|
||||
except for the one peer a joining machine needs, and the token carries that peer. So the sequence
|
||||
becomes:
|
||||
|
||||
```
|
||||
0 the node has an underlay address the machine's own
|
||||
1 the node makes its tunnel key before any token; it prints the public half
|
||||
2 a token is issued for that key its address assigned, and the hub sent it as a peer
|
||||
3 the tunnel comes up to the hub from the token alone: the hub's endpoint and key, its address
|
||||
4 the node dials the bus OVER THE TUNNEL, at the bus's private address
|
||||
5 it proves itself, and is proved to enrolment, checking the key is the one the token named
|
||||
6 the rest of the overlay the whole peer set, delivered as files
|
||||
7 names, filtering, routes as before
|
||||
```
|
||||
|
||||
The link no longer stays on the underlay. The bus is reached over the tunnel by every machine,
|
||||
including one that is joining, so it is never opened to the internet. The precondition becomes: **the
|
||||
hub's tunnel must be dialable by every node, at a stable address.** That port answers nothing to a
|
||||
key it does not know.
|
||||
|
||||
**Whether the link should later move onto the overlay, with the underlay as fallback, is
|
||||
[open](../../02-DECISIONS/0007-connectivity.md).** It is a decision rather than a derivation: the
|
||||
gain is which network carries bytes, not what an attacker can reach, since the link is already
|
||||
@@ -263,8 +298,10 @@ expensively enough to be worth restating:
|
||||
- **A node must not pin its own public name locally.** The duplicate record breaks resolution of
|
||||
that name for everything else that needs it.
|
||||
|
||||
**What the host receives:** the resolver's configuration, as files, listing every peer's internal
|
||||
name and overlay address.
|
||||
**What the host receives:** what to ask, not what to answer. The mesh has **one resolver**, holding
|
||||
every node's internal domain; a node asks it first and a public resolver only when it is silent
|
||||
([ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md),
|
||||
[ADR 0196](../../02-DECISIONS/0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md)).
|
||||
|
||||
**What goes away:** the `/etc/hosts` floor. It exists because a node had to reach the mesh
|
||||
database before its own DNS existed; with [ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)
|
||||
@@ -322,6 +359,43 @@ not a list of containers.
|
||||
somebody starts by hand is not the mesh's to configure, and reaching into every container on a
|
||||
machine — declared or not — is what a nameserver in `resolv.conf` would be for.
|
||||
|
||||
### One resolver for the mesh
|
||||
|
||||
*2026-10-03.* **The mesh's names live in one place: the module holding `mesh-resolver`**, a mesh-scoped
|
||||
seat of capacity one, placed on the node every tunnel converges on. It holds one wildcard per node —
|
||||
`<node>.internal` and everything under it — and listens on the private network only. It answers the
|
||||
mesh's names from what it holds and forwards every other name, giving the public answer.
|
||||
|
||||
**Every node asks it for everything, and a public resolver only when it is silent.** The module
|
||||
holding `node-resolver-config` writes `/etc/resolv.conf` naming `mesh-resolver` first and a public
|
||||
resolver second, with a short timeout and one attempt: the C library moves to the second only when the
|
||||
first does not answer — the anchor or the tunnel down, a captive portal holding the tunnel back — so
|
||||
public names keep resolving then, and `.internal` is never asked of a public resolver while the mesh's
|
||||
answers. Containers take the same two from their machine, the runtime copying non-loopback resolvers
|
||||
into every container, so the runtime is given no `dns` of its own
|
||||
([ADR 0196](../../02-DECISIONS/0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md),
|
||||
replacing ADR 0194's per-node `systemd-resolved` stub).
|
||||
|
||||
**No node holds a copy.** The per-node resolver, its zones file and the mesh's region of `/etc/hosts`
|
||||
go: every resolution fault found on 2026-10-03 was a copy disagreeing with the truth — a hosts file
|
||||
read once at start, an operator's old line beside the mesh's, a node's resolver lent to a LAN. No
|
||||
member's resolver answers a LAN; a router pointing at one is moved first. *Checked by each node's
|
||||
`/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.*
|
||||
|
||||
### The resolver, built
|
||||
|
||||
*2026-08-31.* **A service is reached at `<service>.<node>.internal`** — the first label is the
|
||||
@@ -396,7 +470,22 @@ that module and nothing else.
|
||||
the argument for the table in ADR 0009 being a table: the pattern is only obvious once seen, and
|
||||
the cost of not seeing it is inventing a mechanism that already exists.
|
||||
|
||||
### And the public names a proxy serves must resolve in the mesh too
|
||||
### The mesh resolves only its own names; a public name resolves publicly
|
||||
|
||||
**The mesh's resolver holds each node's internal domain and nothing else** — `<node>.internal` and
|
||||
everything under it, so every route's internal name `<label>.<node>.internal` with no line of its own
|
||||
([ADR 0151](../../02-DECISIONS/0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md)).
|
||||
**A node's public domains — one or more — are never given a private answer**: it is forwarded and resolves to the
|
||||
public address, from a member and from anything else the resolver answers — a resolver may serve a
|
||||
machine's LAN, and a phone on that LAN must get the address it can reach
|
||||
([ADR 0191](../../02-DECISIONS/0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)). Inside the
|
||||
mesh, a routed service is reached, and certified by the internal authority, under its internal name.
|
||||
*Checked by the controller's tests — the roster names the machines and no routed name —
|
||||
and on a machine by asking its resolver for a public name the mesh serves: the answer is the public
|
||||
address.*
|
||||
|
||||
*What follows is how the mesh got here, kept because the reasoning it rejects is the expensive half to
|
||||
rediscover.*
|
||||
|
||||
*2026-09-09, found by an internal certificate authority that could not issue.* The mesh writes every
|
||||
`<node>.internal` name into every declared container and treats the public names a proxy serves as a
|
||||
@@ -419,6 +508,13 @@ would go stale the day one changes. The mesh propagates the names it was told to
|
||||
knows nothing about what they mean
|
||||
([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)).
|
||||
|
||||
*2026-10-03, withdrawn.* Publishing public names with private answers turned every resolver that also
|
||||
serves a LAN into an outage for that LAN's non-members — a phone was handed the control-node's tunnel
|
||||
address for the mail server — while every check, run from a member, passed. Its reason had gone: routes
|
||||
have internal names since ADR 0151, and the proxy certifies public names from a public authority and
|
||||
internal names from the internal one. Superseded by the rule at the head of this section
|
||||
([ADR 0191](../../02-DECISIONS/0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)).
|
||||
|
||||
## 3 — Exposure
|
||||
|
||||
Settled by [ADR 0007](../../02-DECISIONS/0007-connectivity.md); summarised here because
|
||||
@@ -743,6 +839,16 @@ tests over a fixture report check the recording, the preview's fates, the status
|
||||
predicate. Live: the home server's record names the predecessor's chain as *other* and `status`
|
||||
names the machine until the chain is removed by hand.
|
||||
|
||||
*2026-10-02, [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md):* removing what
|
||||
the host reports as *other* is reached through the packet filter seat's `remove` verb, an operator's act
|
||||
by name on the bus; the seat also serves `rules` and `reload`, and its holder's runtime declares the
|
||||
`NET_ADMIN` capability on the machine's network. See design 33.
|
||||
|
||||
*2026-10-02, [ADR 0180](../../02-DECISIONS/0180-the-found-front-end-is-uninstalled-once-a-machine-is-converged.md):*
|
||||
once a machine is converged, the front end it was found with is uninstalled, not merely disabled — the
|
||||
packet filter's holder declares its package absent after the mesh's filter is loaded, and a return to
|
||||
adopted then enables nothing. The rollback path ADR 0100 kept on disk is given up on purpose.
|
||||
|
||||
## 5 — Certificates
|
||||
|
||||
**Two authorities, kept separate on purpose.**
|
||||
@@ -836,13 +942,15 @@ is unchanged. **The same code path certifies against an internal authority as ag
|
||||
only the issuer differs.** That is what makes trusted certificates possible for a mesh whose names
|
||||
the public internet cannot resolve.
|
||||
|
||||
**And it does not work until the routed name resolves inside the mesh** — the §2 finding above,
|
||||
arriving here because this is what needed it. The authority's challenge reaches the routed name only
|
||||
once that name is in internal resolution; a public authority is handed that dependency by public
|
||||
DNS, and an internal one has to be handed it by the mesh. *Checked by a handshake to a routed name
|
||||
that verifies against the internal root and nothing else — which cannot succeed unless the issuer
|
||||
first reached the name to certify it*
|
||||
([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)).
|
||||
**The internal authority certifies internal names; a public one certifies public names.** The
|
||||
authority's challenge reaches the name it certifies, so each certifies what it can resolve: the
|
||||
internal authority a route's `<label>.<node>.internal`, which the mesh resolves, and a public authority
|
||||
the public name, which public DNS resolves. A proxy holds both, and a public name is never certified
|
||||
by the internal authority. *Checked by a handshake to a route's internal name that verifies against
|
||||
the internal root and nothing else, and one to its public name that verifies against the public
|
||||
roots* ([ADR 0191](../../02-DECISIONS/0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)).
|
||||
Until 2026-10-03 this paragraph had the internal authority certify public names, which needed them
|
||||
resolved inside the mesh ([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)).
|
||||
|
||||
## 6 — One statement behind exposure, filtering and certificates
|
||||
|
||||
@@ -927,6 +1035,11 @@ The list is worth having in one place, because it is most of the argument:
|
||||
|
||||
## Open
|
||||
|
||||
- **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
|
||||
matching question — they were one question. Nothing takes over. WireGuard has no failover, the
|
||||
@@ -948,10 +1061,6 @@ The list is worth having in one place, because it is most of the argument:
|
||||
operator's to move between meshes, but the manifest layer still stores it as a literal — so today
|
||||
the composition is a per-node override rather than the design. The interpolation that would let a
|
||||
module carry a label and a node carry the domain, and the mesh join them, does not yet exist.
|
||||
- **Publishing route names into internal resolution.** The same ADR requires a granted route to be
|
||||
resolvable inside the mesh, not only routable from outside it; the mechanism that writes
|
||||
`<node>.internal` into containers does not yet also write the routed names, which is why an
|
||||
internal issuer cannot currently validate one without a hand-placed entry.
|
||||
|
||||
## The hub adopts the predecessor's tunnel
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ status: in-progress
|
||||
code:
|
||||
- mesh-controller internal/licences
|
||||
- mesh-controller cmd/mesh-controller/licence.go
|
||||
updated: 2026-09-05
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0024-model-access-is-a-provision.md
|
||||
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
||||
- 02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md
|
||||
- 02-DECISIONS/0055-model-access-is-answered-by-a-licence-or-a-node.md
|
||||
@@ -96,6 +97,14 @@ So `(node, module)` tells them apart, and asking for a licence per session neede
|
||||
identity. Checked rather than argued: two sessions on one machine hold different licences, each is
|
||||
given its own key, and releasing one leaves the other.
|
||||
|
||||
*2026-10-02:* the operator's own agent at a terminal is **not** a consumer of this provision: it is
|
||||
coupled to an Anthropic grant and nothing else, so it uses the `anthropic-licence-manager` seat, whose
|
||||
holder owns the Anthropic licences, their bindings and their rotation, and hands each node's agent its
|
||||
token over the bus
|
||||
([ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md),
|
||||
[36](36-the-operators-agent-on-a-machine.md), [39](39-the-anthropic-licence-manager.md)). This
|
||||
provision stays for the consumers that do not care which vendor answers.
|
||||
|
||||
**What is still open is the rest of the gap, and it is the harder half.** A *worker* is not one
|
||||
per machine — many can run on one, from one module — so `(node, module)` cannot name them apart
|
||||
and this reasoning does not extend to them. That belongs with
|
||||
|
||||
@@ -4,10 +4,12 @@ status: proposed
|
||||
code:
|
||||
- mesh-controller cmd/mesh-builder
|
||||
- mesh-controller internal/builder
|
||||
- mesh-catalog modules/builder
|
||||
updated: 2026-10-01
|
||||
- mesh-catalog modules/build-agent
|
||||
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
|
||||
@@ -268,6 +270,29 @@ ships one and wrong for code the mesh built, which has no unit until the mesh wr
|
||||
**Tools, hooks and consumers are not further modes**, which is the test of whether three is the
|
||||
right number: they are loaded by a tool host, and a tool host is a process that stays up.
|
||||
|
||||
## Where a build runs
|
||||
|
||||
**On whichever machine holding the build role is idle** ([ADR 0190](../../02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md)).
|
||||
The role is `node-build-agent`, a node seat; its holder is the `build-agent` module, assignable to
|
||||
every machine with a container runtime. The controller asks the role, never a machine: a tier's asks go
|
||||
onto the seat's one work queue together, and each holder pulls one at a time when it is idle, so a
|
||||
tier of many images is built by as many machines as hold the seat and are online, and a machine that
|
||||
is off builds nothing and blocks nothing. What a holding machine needs is what the builder always
|
||||
needed — a container runtime, the artifact store and the package registry as provisions, a workspace,
|
||||
the bus credential — said once in the module's manifest. The outcome names the machine that built it.
|
||||
|
||||
This is the bus's shared-work pattern, not a build-specific one: any module declaring a node seat with
|
||||
`accepts` has its work shared by its holders the same way. Building is the first use.
|
||||
|
||||
*Built and proven live 2026-10-03.* `build-agent` holds `node-build-agent` on all four machines; the first
|
||||
build taken by a workstation's agent was a catalogue module at 09:35 UTC; the one-holder `builder` is
|
||||
retired. The switch found five gaps, each an issue: a worker whose type changed stranded its holder
|
||||
([206](../../04-ISSUES/206-a-seats-worker-changing-type-strands-the-holder-and-the-build-that-would-fix-it/00-report.md)),
|
||||
a re-made worker replayed the stream's history ([207](../../04-ISSUES/207-a-re-made-worker-replayed-every-ask-the-stream-kept/00-report.md)),
|
||||
a seat's worker is made only when the controller starts ([208](../../04-ISSUES/208-a-seats-worker-is-made-only-when-the-controller-starts/00-report.md)),
|
||||
a module's identifier must fit the tightest backend's key (the slug), and an idle machine's empty fetch
|
||||
was read as the end (fixed in the controller the same day).
|
||||
|
||||
## A build says what it does, as it happens
|
||||
|
||||
*2026-10-01 — [ADR 0157](../../02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md).*
|
||||
@@ -293,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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user