Compare commits

...
Author SHA1 Message Date
jochen b740ce9321 Connectivity: walk the five contexts through the network layers, so a reader can see which layer each one acts at and read a failure from the bottom up
mesh/delivery ready: it delivers once merged
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
2026-10-10 14:12:48 +02:00
mesh-admin a5ff354eee Merge pull request 'ADR 0274, progressive insight: the router judges whether a channel can carry the ask (issue 370)' (#273) from decisions/0274-insight-the-router-judges-the-channel into main 2026-10-10 11:11:29 +00:00
mesh-admin 8af56cf2ae Merge pull request 'Playbook 03 takes an issue's number from the issue tracker' (#274) from process/issue-numbers-from-the-tracker into main 2026-10-10 11:09:15 +00:00
jochen beaeedca79 Playbook 03 takes an issue's number from the issue tracker
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Issue numbers collided while each author read main and the open pull
requests; since 2026-10-10 the tracker (ADR 0275) hands them out. The
playbook and the hq-new-issue skill now open every issue there, keep a
folder only for a design or governance issue, and say where its status
lives until slice 3. Research 042's finding that the SDK had no
compare-and-set is corrected, and to-be 53 names the module's code.
2026-10-10 13:06:13 +02:00
jochen 91f2a61763 ADR 0274, progressive insight: the router judges whether a channel can carry the ask
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
2026-10-10 13:04:22 +02:00
mesh-admin cf529b567c Merge pull request 'ADR 0275, to-be 53: the issue tracker's module is mesh-issues, holding the seat issue-tracker' (#272) from decisions/0275-the-issue-trackers-module-is-mesh-issues into main 2026-10-10 10:59:54 +00:00
jochen 8d9eae7224 ADR 0275, to-be 53: the issue tracker's module is mesh-issues, holding the seat issue-tracker
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
The operator named the module on 2026-10-10: the mesh's own parts carry the
mesh- prefix, and the seat is the role. A dated insight in ADR 0275 says so,
and that compare-and-set state was already in the runtime (ADR 0259): slice 1
added the SDK's Increment. To-be 53 and the glossary say the same, and the
design is handed to mesh-catalog and mesh-sdk (in progress).
2026-10-10 12:52:37 +02:00
mesh-admin 2798306de7 Merge pull request 'ADR 0276: merges are assembled in a rolling window, and each batch is delivered by one walk (issue 362)' (#271) from decisions/0276-merges-are-assembled-in-a-rolling-window into main 2026-10-10 10:44:53 +00:00
jochen d159bfa36a ADR 0276: merges are assembled in a rolling window, and each batch is delivered by one walk
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Two catalogue merges 18 s apart opened two walks; the second superseded the
first and nothing gave it its word for 58 minutes (issue 362). The operator
decided that merges are collected in a rolling merge window (90 s, at most
10 min) and each batch is walked once, one commit per repository (the later
one of a repository, which contains the earlier), one walk at a time; the
walk-to-delivery bookkeeping is repaired too and every wait has a condition.
Amends to-be 47 and 10, adds merge window and batch to the glossary, and
corrects the issue's account of the S16 condition.
2026-10-10 12:39:13 +02:00
mesh-admin 9395c752cc Merge pull request 'ADR 0275 and to-be 53: the mesh numbers and tracks its issues in a module of its own' (#270) from decisions/the-mesh-keeps-its-own-issues into main 2026-10-10 10:34:51 +00:00
jochen 015ff1adfb Graduate research 042 into ADR 0275 and to-be 53, so issue numbers stop colliding
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Numbers taken by reading a repository many branches write at once collide;
one counter in a module of the mesh's own, advanced by compare-and-set,
ends that at the source. The operator delegated the five open questions
on 2026-10-10; each is decided as the research recommended. The glossary
gains the issue tracker, retires "ticketing system" and lists ticket as a
homonym, since ADR 0258 already uses it for an answer's one-time token.
2026-10-10 12:31:32 +02:00
mesh-admin 835d7acc1a Merge pull request 'ADR 0274: an agent proposes a shared instruction section, and every node takes it only on the operator's warrant' (#268) from decisions/0274-shared-instructions-on-the-operators-approval into main 2026-10-10 10:09:39 +00:00
jochen b18bf66ae2 ADR 0274 accepted, with its two limits: to-be 36 rests on it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-10 12:06:51 +02:00
jochen a211644102 ADR 0274: variation selectors, blank letters and every heading form refused 2026-10-10 12:06:38 +02:00
jochen 842ed805cc ADR 0274: hold the review's findings — invisible text, any @, the record judged at decide, older approvals never undo later ones 2026-10-10 12:06:38 +02:00
jochen 891608814e ADR 0274: an agent proposes a shared instruction section, and every node takes it only on the operator's warrant
The operator asked for mesh tools to add shared instructions and context. Issue 340 made that text the
terminal's until a surface of the operator's own exists; the operator channel is that surface. Proposed,
for the operator to accept.
2026-10-10 12:06:38 +02:00
mesh-admin 7c866e7356 Merge pull request 'Issue 364: the merge gate refuses a module that uses a seat another module declares' (#269) from issues/364-the-gate-refuses-a-module-that-uses-a-seat-another-declares into main 2026-10-10 02:05:20 +00:00
jochen 7b096f2471 Issue 364: the merge gate refuses a module that uses a seat another module declares
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
2026-10-10 03:53:06 +02:00
mesh-admin d457921486 Merge pull request 'Issue 363: ADR 0267 was decided and never built' (#266) from issues/363-adr-0267-was-decided-and-never-built into main 2026-10-10 01:34:44 +00:00
mesh-admin 014554ce9b Merge pull request 'Research 042: the mesh keeps its own issues' (#267) from research/042-the-mesh-keeps-its-own-issues into main 2026-10-10 01:02:22 +00:00
mesh-admin 591731ab54 Merge pull request 'Issue 341: a merge check judges the pull request's head, not what merging it makes' (#242) from issues/341-a-merge-check-judges-the-head-not-the-merge into main 2026-10-10 01:02:09 +00:00
mesh-admin b106518cda Merge pull request 'Issue 338: a module built from a shared repository moves on every merge to it' (#231) from issues/338-a-module-built-from-a-shared-repository-moves-on-every-merge-to-it into main 2026-10-10 01:01:58 +00:00
jochen 028f3b6ae5 Research 042: the mesh keeps its own issues
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Opens the effort the operator asked for on 2026-10-10: an issues module on the bus that hands out
numbers atomically, so agents stop colliding on them, and takes in the 357 issue folders. Findings
cover the first slice, where the truth lives, the import, conditions, views, and hits as a model.
2026-10-10 02:58:37 +02:00
jochen f45f5f6f20 Issue 363: ADR 0267 was decided and never built, so a controller merge still moves three modules
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The operator took the rule as live. Track its build, as issue 354 does a deferred rename.
2026-10-10 02:46:35 +02:00
mesh-admin 503e144ab6 Merge pull request 'Issue 361: the setuid search shows its progress, and the operator can ask for a fresh one at the terminal' (#265) from issues/361-the-setuid-search-shows-its-progress into main 2026-10-10 00:06:34 +00:00
jochen 2d0a6ba4bc Issue 361: a setuid find counts only where the agent account can execute it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-10 02:00:09 +02:00
jochenandClaude a8e9a9afd4 Issue 361: the setuid search shows its progress, and the operator can ask for a fresh one at the terminal
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 01:55:52 +02:00
mesh-admin c2d98bc61b Merge pull request 'ADR 0266: the control node's agent account is named nox' (#264) from decisions/0266-the-agent-account-is-named-nox into main 2026-10-09 23:32:58 +00:00
mesh-admin 851a5e9f45 Merge pull request 'Issue 362: two merges seconds apart left their delivery waiting for a person' (#263) from issues/362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person into main 2026-10-09 23:32:52 +00:00
jochen 997e422a32 ADR 0266: the control node's agent account is nox, the operator's choice; the ways not judged are tracked by issue 344
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-10 01:29:42 +02:00
jochen 496452fb54 Issue 362: two merges seconds apart left their delivery waiting for a person, because a walk at a later commit matched no live delivery; issue 349 points at it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-10 01:28:33 +02:00
mesh-admin 511cea04f2 Merge pull request 'Issue 361: the control-node's agent account is never judged, because its search for setuid programs cannot finish in fifteen minutes' (#261) from issues/361-the-control-nodes-agent-account-is-never-judged into main 2026-10-09 23:18:48 +00:00
jochen 94719d4662 Issue 361: say when a search runs as the fix does it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-10 01:12:19 +02:00
jochen 46f89cd102 Issue 361: record the review of the fix and what it changed
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-10 01:12:02 +02:00
mesh-admin 288da29d94 Merge pull request 'Research 041: the agent's session runs confined, instead of being proven by a disk-wide search' (#262) from research/041-the-agents-session-runs-confined into main 2026-10-09 23:07:33 +00:00
jochen 4bc0919102 Research 041: the agent's session runs confined, instead of being proven by a disk-wide search
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The setuid search did not finish within its bound on the control-node, so no
approval proven there is honoured; a confined start may answer the question
ADR 0259 asks directly, at a cost that does not grow with the disks.
2026-10-10 01:04:02 +02:00
jochen c0c7515e06 Issue 361: the control-node's agent account is never judged, because its search for setuid programs cannot finish in fifteen minutes
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-10 01:03:51 +02:00
mesh-admin a8b9be8d7f Merge pull request 'Issue 360: the operator cannot choose a notification's action on a touchpad' (#260) from issues/360-the-operator-cannot-choose-a-notifications-action-on-a-touchpad into main 2026-10-09 22:21:30 +00:00
jochen fe970e2f00 Issue 360: the operator cannot choose a notification's action on a touchpad
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The desk's actions sit on dunst's middle click, which a touchpad cannot give, and the context menu
names a command dunst's PATH does not hold. Located in the dunst module; dunst's own code settles
what a tap with do_action does.
2026-10-10 00:07:01 +02:00
mesh-admin c51aedea22 Merge pull request 'Issue 359: a secret given reached the operator as "needs a look", and nothing said why' (#259) from issues/359-a-secret-given-reached-the-operator-as-needs-a-look into main 2026-10-09 22:00:16 +00:00
jochen 163521f32e Issue 359: a secret given reached the operator as "needs a look", because its words held a clock time and the fallback was silent
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-09 23:40:57 +02:00
mesh-admin 32c0f6efb1 Merge pull request 'Issue 358: a mesh directory outside a home is changed by path, through any link; issue 356 points at it' (#258) from issues/358-a-mesh-directory-outside-a-home-is-changed-by-path into main 2026-10-09 18:08:04 +00:00
jochen e68d0ffa1d Issue 358: a mesh directory outside a home is changed by path, through any link; issue 356 points at it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The confirmation review of issue 356 found that the by-path chmod and chown of an ordinary mesh directory outside a home follows any link in its path. Issue 356 closed it for a handed-over directory, on every apply; the general gap is older and wider, and is recorded here rather than fixed in passing.
2026-10-09 19:59:06 +02:00
mesh-admin fbaa8e16e6 Merge pull request 'Issue 353: the controller asked before the bus held its grant' (#253) from issues/353-the-controller-asked-before-the-bus-held-its-grant into main 2026-10-09 17:57:38 +00:00
mesh-admin 0e7af9988a Merge pull request 'Issue 356: the operator's command ran a stale engine, and a hand-over had no channel; ADR 0272 gains the operator's rule and words.py the daemons' lines' (#256) from issues/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel into main 2026-10-09 17:57:18 +00:00
jochen 2cb779d06f Issue 354: quote the condition's old words, which the daemons' lines check refuses as a line to type
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-09 19:46:36 +02:00
jochen d1b42b7430 Merge remote-tracking branch 'origin/main' into issues/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check fail: its merge-check.sh failed: words: 2 problem(s)
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 19:46:03 +02:00
jochen 19776115ef Issue 356: the subject proves nothing, the signature does; the apply refuses a link at use
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
The review of 2026-10-09 proved a responder can reach the hand-over's subject through a reply, and that the apply followed a link the hand-over had refused. The record no longer says only the controller may publish there: it says the ask is signed with the mesh's key and verified by the engine, and that a handed-over directory is changed through a descriptor that followed no link. To-be 45's writers table gains the row for a hand-over asked of a machine.
2026-10-09 19:45:54 +02:00
jschoubben adbd57a1f9 Issue 353: the condition cleared at 18:11:44, about 15 minutes after the last refusal, not an hour after
mesh/repo-check pass: its merge-check.sh passed
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/delivery delivered
2026-10-09 19:35:09 +02:00
mesh-admin b962e87cae Merge pull request 'cycle.py: skip a rejected option by its ## section, so one under a ### subheading is skipped too' (#257) from fix/cycle-skips-rejected-options-by-their-section into main 2026-10-09 17:33:52 +00:00
jochen 189dbcef7a Skip a rejected option by its chapter, so one under a ### subheading is skipped too
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
cycle.py skipped a rejected option only when "Options rejected" was its nearest heading; a record that
lists its options under ### subheadings of "## Considered Options" had them held as deferrals.
2026-10-09 19:32:16 +02:00
mesh-admin 169200fe55 Merge pull request 'Issue 354: a decided rename was never scheduled, and a record that defers work names its issue' (#255) from issues/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live into main 2026-10-09 17:31:45 +00:00
jochen 2349f4bb63 Hold an insight to the deferrals it speaks about, and name what ADR 0273 left undecided
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A review found the check too lenient: an issue cited as context beside a deferral passed it, and one
insight excused every deferral in a record. The tracking issue is now named as tracking it, an insight
covers only its section or what it quotes, and the phrases include "not decided here", "postponed" and
"future work". ADR 0273's four undecided items get issue 357.
2026-10-09 19:29:18 +02:00
jochen 85da2fb93f Issue 354: a decided rename was never scheduled, and a record that defers work names its issue
ADR 0244 left the code renames of the node-engine and the tool runner for later with no issue, owner or
date, and two days on the operator believed them live. Open the issue with the inventory, the risk and a
staged plan; add the rule to playbooks 02 and 04, as a dated insight on ADR 0244, and as a cycle.py check
that found the same gap in ADR 0270 (issue 355).
2026-10-09 19:27:05 +02:00
mesh-admin 1365ec17c8 Merge pull request 'ADR 0273: the agent's home is the module's, and the operator's personal rules and lessons are registered through it' (#254) from decision/0273-the-agents-home-configuration-is-the-modules into main 2026-10-09 17:26:55 +00:00
jochen a5433164c5 ADR 0273: act on drift at once, watch settings.local.json and plugins, and move unrendered files after adoption
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Review of hq #254: a hand edit is written back on the look that finds it, and a key that
decides what runs is never taken over; the guard and the condition cover settings.local.json,
plugins and plugin installs; after a node's first adoption an unrendered file is moved to the
copies; autoMode is a permission rule; the named leftovers, the owned and excluded entries,
pending requests with the exact nox line, and the agent's existing corrections as first lessons.
2026-10-09 19:24:35 +02:00
jochen 858e5413fa Issue 356: the operator's command ran a stale engine, and a hand-over had no channel
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The engine on every node's PATH was the one a person copied at installation, a build behind every delivery; and the way out of issue 339 was a terminal act at the machine, which ADR 0272 and the operator's rule of the same day make impossible to type. The record, located in the engine, the controller and the catalogue's check, with the fixes' pull requests; ADR 0272 gains the rule as a dated insight: the operator types one command, nox, and every text addressed to the operator names a nox line. words.py now refuses a daemon's line, with --list lines for the catalogue's copy; the lifecycle and the operator's-machine designs and issue 343's quotations are reworded.
2026-10-09 18:36:58 +02:00
jochen 2b1edf046d ADR 0273: the agent's home is the module's, and the operator's personal rules and lessons are registered through it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Research 040 measured the homes on four nodes: a skill retired a week ago on all four, the
operator's rules on one, nothing raised. The module now owns every configuration file under
the agent's home, excludes the program's working state by name, and raises agent-home-drift
on a hand edit; a fourth scope carries the operator's personal rules to every node; an agent
proposes a lesson when corrected and the operator accepts it at the terminal or on a warrant.
To-be 36 §10 and to-be 40 WP7–WP10 say what is built; ADR 0216 and 0243 gain notes.
2026-10-09 18:11:26 +02:00
jschoubben 2bceb5fbaa Issue 353: the controller asked before the bus held its grant
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 18:09:59 +02:00
mesh-admin c613cf0b82 Merge pull request 'Issue 352: a gate read another send's report, and a put-back chose a controller older than its store' (#252) from issues/352-a-gate-read-another-sends-report-and-a-put-back-chose-a-controller-older-than-its-store into main 2026-10-09 15:40:16 +00:00
jschoubben 903340516c Issue 352: a gate read another send's report, and a put-back chose a controller older than its store
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The 2026-10-09 16:19-16:36 sequence on the control node: a release's gate
held the machine's report against a newer send another plan made, failed
three healthy builds and put them back to a controller that could not
read the store; that controller then passed the newer plan's gate from
its own health. Measured and guessed apart; the fix in review.
2026-10-09 17:15:43 +02:00
mesh-admin 793e98d45a Merge pull request 'Issue 351: a held delivery whose module a backlog walk carried to every machine is never covered' (#251) from issues/351-a-held-delivery-whose-module-a-backlog-walk-carried-is-never-covered into main 2026-10-09 15:01:44 +00:00
jschoubben 261bee1cab Issue 351: a held delivery whose module a backlog walk carried to every machine is never covered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The catalogue's #125 stayed held 28 hours after ADR 0270 went live. The record covers a held merge when a
later delivered walk sent its module everywhere, as the machine steps say; a build held back for a person
and sent on the operator's word travels by a backlog walk, which gives one step per machine and whose end
mesh-delivery read for nothing. Report, diagnosis located in mesh-delivery, the fix in mesh-catalog #163,
and a progressive insight on ADR 0270's one-machine consequence, which this widens.
2026-10-09 16:53:11 +02:00
mesh-admin eec84794e3 Merge pull request 'ADR 0259: an ask is a message on the bus, any channel answers it, and the asker acts on a warrant' (#211) from decision/asks-answered-on-any-channel into main 2026-10-09 14:30:48 +00:00
jschoubben 3e84e4cb60 ADR 0259: accepted as revised on 2026-10-09
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-09 16:23:44 +02:00
jschoubben 32107428c4 ADR 0259: a rehearsal, not a drill; the announcement at the terminal checked; the operator's steps with nox, and the standard-input gap said
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 15:38:41 +02:00
jschoubben ec9bab5ee8 ADR 0259: name the confirmation review's N1 findings and their fixes — an early desk unlink bars nothing, a trusted party's secret is never given at a desk — and root-free answered by the serving controller alone, a silence never ending an approval, the link bound's residual risk 2026-10-09 15:36:36 +02:00
jschoubben 69c6ff73e6 ADR 0259: after the review of 2026-10-09 — root-free as a positive measure, warrants under ids nobody can guess, canonical digests, the code shown to a person or not at all, unlinking bound to the account and possible in the first hour, break-glass named, give as built; the operator's desktop client and the controller's write to the router's state stated as known gaps (issue 344) 2026-10-09 15:36:36 +02:00
jschoubben 8273faa4f0 ADR 0259: an ask the controller could not deliver is a condition of its own 2026-10-09 15:36:36 +02:00
jschoubben de5c89a211 ADR 0259: the drill as the live acceptance, another account's answer told to the operator, an ask's message never folded 2026-10-09 15:36:36 +02:00
jschoubben ba49876e48 ADR 0259: bind a warrant to the exact ask and act, count the login shell where execute is served (ADR 0268), fail closed where root is not measured, and keep what is trusted out of settings (issue 342) 2026-10-09 15:36:36 +02:00
jochen 7e158c739e Say how a module of its own account is served, and how the root probe is checked 2026-10-09 15:36:36 +02:00
jochen 7dd83d4435 Record the operator's choice for the control node, and the second way an agent reaches root there 2026-10-09 15:36:36 +02:00
jochen a04f1b90a0 Name the router, not the controller's retired verbs, where the glossary and to-be 46 still did 2026-10-09 15:36:36 +02:00
jochen c08b621491 Revise ADR 0259 after the security review: the router and verified channels get bus accounts of their own, verified-sender follows placement, and unlinking is asked of the linked account 2026-10-09 15:36:36 +02:00
jochen b63d27ded1 Keep an ask's tickets in its own record, where its closing ends them 2026-10-09 15:36:36 +02:00
jochen 1e51c062f6 Say which defects the channel fixes and which ask is refused, as built 2026-10-09 15:36:36 +02:00
jochen 1ebbf9007a Name the channel's failure and standing as intake events, and where the router reads each kind's capabilities 2026-10-09 15:36:36 +02:00
jochen 1714132a08 Accept ADR 0259 with the operator's two decisions, and amend to-be 46 to it 2026-10-09 15:36:36 +02:00
jochen 9f66452fff ADR 0259 (proposed): let any module ask a person and any channel answer, so Telegram is not built into the controller 2026-10-09 15:36:36 +02:00
mesh-admin 80b02dc9a0 Merge pull request 'Issue 348: a resolver that stopped answering ran on unnoticed, and builds sent after it were blamed' (#249) from issues/348-the-resolver-stopped-answering-and-nothing-restarted-it into main 2026-10-09 13:25:22 +00:00
jschoubben fbd00a16ca Issues 348 and 349: the review's answers — gaps, parts, D2 unchanged, the newest merge in any state
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-09 15:05:42 +02:00
jschoubben 17b64270b0 Issue 348: the manifest test takes either binding until the catalogue merges
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 14:22:17 +02:00
jschoubben 3acc1ec219 Issue 349: a merge acted on late superseded the merge after it, and built the older commit
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Plans of a branch superseded each other by when they were made; the
catch-up's late plan of an older merge took over a newer one, once a
security fix to the forge's module. Recorded with the fix in review.
2026-10-09 14:12:42 +02:00
jschoubben 582be67f05 Issue 348: a resolver that stopped answering ran on unnoticed, and builds sent after it were blamed
Records the 2026-10-09 outage on the control node: what was measured
(the unit ran, the private address refused; a restarted node-engine's
undecided statement cleared the network condition and its reopening
dated the fault after the send), what the reproduction showed of
bind-dynamic, what is still guessed, and the fixes in review.
2026-10-09 14:12:42 +02:00
mesh-admin fce1bbb432 Merge pull request 'ADR 0272: progressive insight — the terminal's mark, the login session, ordinary lines are reads and settings' (#248) from insight/0272-the-terminals-mark-and-the-login-session into main 2026-10-09 12:02:53 +00:00
mesh-admin 3b99661b27 Merge pull request 'Issue 344: the known gaps in the agent's boundary, accepted for now' (#244) from issues/344-known-gaps-in-the-agents-boundary into main 2026-10-09 11:59:23 +00:00
jochen 37bc302d7d ADR 0272's note: what the login-session rule does not do, and the class=user record
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-09 13:56:04 +02:00
jochen 57cfdb5cf9 Issue 344: gaps 12 and 13 closed by mesh-catalog #153 and mesh-controller #177
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-09 13:49:54 +02:00
jochen cd70eb7f6a ADR 0272: progressive insight — the terminal's mark, the login session, ordinary lines are reads and settings
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 13:42:17 +02:00
mesh-admin 6ea93e824a Merge pull request 'Issue 347: a pull request opened after its push is never checked, and nothing says so' (#247) from issues/347-a-pull-request-opened-after-its-push-is-never-checked into main 2026-10-09 11:38:40 +00:00
mesh-admin 54d974f178 Merge pull request 'ADR 0272 (accepted) and issue 343: the controller's terminal has no command; mesh-cli on every node' (#243) from decision/0272-mesh-cli-the-operators-command into main 2026-10-09 11:38:36 +00:00
jochen fc449617be Issue 347: a base requiring only mesh/repo-check counts, and the delivery's owner says it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The confirmation review: hq requires only mesh/repo-check, so a watcher
that looked for mesh/merge-gate alone would never have said this
repository's own unchecked pull request. And the delivery domain's own
condition, a delivery held past its bound, says it rather than a provider
standing.
2026-10-09 13:16:27 +02:00
jochen 4b5793144d ADR 0272: §4 as built after the confirmation review, ADR 0266 linked now it is merged
mesh/repo-check pass: its merge-check.sh passed
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/delivery delivered
An ordinary line meets the generic verb's reading forms and never a
terminal-only command (node account and node agent-account by ADR 0266,
token, operator, a secret's export); a terminal line drops ADR 0266's
served mark, so it reads as the terminal. How it is checked says the
withholding tests search base64 too and fail when what they guard goes,
and the control-node refusal is tested through the acts.
2026-10-09 13:15:59 +02:00
jochen 3bdd4e8040 Merge remote-tracking branch 'origin/main' into decision/0272-mesh-cli-the-operators-command 2026-10-09 13:15:36 +02:00
jochen d3cb9fedf4 ADR 0272 accepted (the operator, 2026-10-09)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
2026-10-09 13:02:45 +02:00
jochen d7c97d02fa Issue 344: name the node-engine's repository as code
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 12:56:54 +02:00
jochen bac7feca77 ADR 0272: the review's answers — every line a call, the socket grants nothing, the terminal does not follow assign, each claim and how it is checked
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
- §3: the node-engine vouches and decides nothing; deadline first, other
  namespaces refused, each account bounded and answered busy; the subject
  is the machine's own in the writers table.
- §4: phase 1 refuses the agent account (phase 2 answers it); an ordinary
  settings line takes the settings verb's path; the controller's module is
  assigned at the terminal only, so the terminal does not follow assign;
  'no wider than today' holds only while its two measured facts hold.
- §5: every line is a call, followed to its answer; 'nothing ran' only of
  a line nobody took, otherwise 'not known' with the call; the answer is
  its asker's alone.
- §7: after the four merge, the operator assigns mesh-cli to every node.
- How it is checked: each claim with its test or measurement, the
  MESH_VERB refusal in mesh-cli named a courtesy, and the sudo and
  agent-account facts read on the control-node today.
2026-10-09 12:56:44 +02:00
jochen 3143f5c698 Issue 344: gap 9 narrowed by mesh-host #61; the tools' sudo and the forge's rule precedence 2026-10-09 12:56:41 +02:00
mesh-admin 2700ef7096 Merge pull request 'ADR 0266 and issue 333: on the control node, agents run under an account of their own' (#225) from decision/0266-agents-run-under-an-account-of-their-own into main 2026-10-09 10:56:11 +00:00
jochen 2ba0d68306 Issue 347: a pull request opened after its push is never checked, and nothing says so
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The forge's announcer reads only repositories whose update time moved; a
pull request opened after its push does not move it. Located in the forge
module, with the fix (mesh-catalog) and how it is checked.
2026-10-09 12:55:26 +02:00
mesh-admin 6a7cf421a9 Merge pull request 'Issue 346: a setting a verb may set is pasted raw into its file, so an untrusted file is an injection point' (#246) from issues/346-a-setting-a-verb-may-set-is-pasted-raw-into-its-file into main 2026-10-09 10:51:03 +00:00
mesh-admin 2ed56a80b8 Merge pull request 'Issue 345: a calendar reminder shows transparent and empty, and the mesh had no tool to look at it' (#245) from issues/345-a-reminder-window-shows-transparent-and-empty into main 2026-10-09 10:50:55 +00:00
jschoubben 0ff6bd2267 Issue 346: user-dirs.dirs is trusted again for now (mesh-catalog #158), the stop-gap before the fix
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-09 12:40:26 +02:00
jschoubben 79f31ca113 Issue 346: a setting a verb may set is pasted raw into its file, so an untrusted file is an injection point
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Found in the review of the picom settings: a backend of xrender"; write-pid-path = "… adds an option of
the caller's. The two files marked trusted false on main are judged (user-dirs.dirs open, dunstrc not),
and typed settings validated at the verb and quoted at render are proposed; until then nothing new goes
trusted false.
2026-10-09 12:35:30 +02:00
jochen cb907d5810 ADR 0266: an agent can merge its own pull request, and the restart condition's quiet counts on the controller's clock
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The record said a merge needs a person; the forge requires no approval and
the merge tool merges once the checks pass, so nothing enforces the
operator's yes. What still remains now says so, and that the restart fix
counts from when the controller first saw the search pending. Item 10 and
routes A and B describe the follow-ups of the review of 2026-10-09.
2026-10-09 12:27:13 +02:00
jschoubben a4e132e47b Issue 345: picom is the first suspect, the graphics stack, and compositing off as the first test
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The operator reports picom leaving a chat window stale after a workspace switch before, never recorded.
The diagnosis now names the graphics stack, puts picom first among the candidates, and makes the first
step at the next empty reminder turning compositing off, which loses nothing; a Betterbird restart comes
after, refused while a message is being written unless the operator says yes.
2026-10-09 12:25:42 +02:00
jochen 11d261e582 Issue 344: the screen is readable through the display server's tools
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 12:19:54 +02:00
jochen 41060224e6 ADR 0272: the agent account's judgement comes from the engine's statement, never from the request
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 12:13:03 +02:00
jochen e143f6b193 Issue 343: name the node-engine's repository as an identifier
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
2026-10-09 12:12:34 +02:00
jochen 57bb4dab74 Issue 343: phase 1 of ADR 0272 is open as four pull requests 2026-10-09 12:12:23 +02:00
jochen 322f3aec36 ADR 0272: a path in a line is read where the controller runs
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 12:11:25 +02:00
jochen e8f0f43f1b Issue 344: an agent can merge its own pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 12:09:17 +02:00
jschoubben 599c3835f0 Issue 345: a calendar reminder shows transparent and empty, and the mesh had no tool to look at it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Records the symptom, what four reminders raised from a throw-away profile showed (drawn every time, at
depth 32), what that rules out, the candidates left, and what to measure at the next real reminder.
Status diagnosing: the cause is not known.
2026-10-09 12:07:49 +02:00
jochen 7d4678c5fb Issue 344: record the known gaps in the agent's boundary, accepted for now by the operator
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 12:03:36 +02:00
jochen 1ad9959b16 ADR 0272: say how the socket keeps ADR 0004, answer the operator and agent accounts only, and keep the bus request out of the SDK (ADR 0039)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 12:01:45 +02:00
jochen 95d78f7011 ADR 0272 (proposed) and issue 343: the controller's terminal has no command
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The records send the operator to "the controller's terminal", and on the
control-node `mesh-controller` is not a command: the controller is a mesh
process with its own account and environment, and nothing puts anything on
PATH. The record proposes mesh-cli, a module on every node whose program
asks the node-engine over a local socket; the node-engine vouches for the
account from the kernel and asks the controller on its node's own subject;
the controller runs the line as the terminal only for the operator's account
where no agent can be that account. The glossary gains mesh-cli, nox (its
zsh alias) and the controller's terminal.
2026-10-09 11:59:34 +02:00
jochen d7999e6fc9 ADR 0266: the setuid search's bound is 15 minutes, and the two routes and the restart condition the review of 2026-10-09 found
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Item 10 said two minutes; the engine's bound is 15, with a two-minute limit
inside it still to settle. What still remains now names a build from another
repository registering a module (mesh-controller 174), the forge's tools
changing its protection (mesh-catalog 153), and the urgent condition every
engine restart raises (mesh-controller 175).
2026-10-09 11:49:25 +02:00
jochen ee314a26ca ADR 0266: say where the boundary stands on 2026-10-09, and the rollout's order
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The routes found around the agent account since the record was written are
closed on main (the store's reader, places and served keys, what every session
obeys, execute withheld on the control node); the record names them, drops the
store gap it said kept the boundary from holding, and says plainly what still
remains, first the login shell's execute on the other nodes. To-be 36 §9 gains
the prerequisites and the order the parts go out in.
2026-10-09 11:03:27 +02:00
jochen e215dd95c4 ADR 0266: record the command allow list, the judge's final lists, and the store gap
The re-review closed the command verb to reads, moved enrolment to the
controller's terminal, widened what the node-engine judges, and found the
store's reader able to read every secret; say each, and that the boundary is
not in force until the store is fixed.
2026-10-09 10:15:47 +02:00
jochen dbc3ec40e0 ADR 0266: say it detects rather than prevents, and bound what review found
The review found ways round the record: a verb that set the accounts, root
following a link below a home, a tool reading any path as the operator, a
restore written beside the agent's files, a stale verdict passing. Record each
bound, the exact ways judged and not judged, the guard on every tool and its
record's limits, and a rollout order the guard does not block.
2026-10-09 10:15:47 +02:00
jochen 537e5b733b ADR 0266: on the control node agents run under an account of their own
The operator chose this remedy for ADR 0259 section 8: an agent there could
become root through the operator account's sudo and docker group, and read the
tool runner's credential. Record the node fact, the node-engine's judgement
the self-check reads, the guard, and how a session starts; issue 333 holds
the measured symptom and to-be 36 the design.
2026-10-09 10:15:47 +02:00
mesh-admin d81ae38970 Merge pull request 'Issue 340: an agent can plant a hook in every agent session on the mesh' (#240) from issues/340-an-agent-can-plant-a-hook-in-every-session into main 2026-10-09 08:00:25 +00:00
jochen cdf0e71d87 Issue 340: removing from or restoring to the operator's own home is refused too
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-09 09:51:57 +02:00
jochen 0badde3a56 Issue 340: say plainly which test refuses the new url
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 09:43:12 +02:00
jochen 523bb9c55a Issue 340: the home scope reached the operator's own home, and bus text still renders
The confirmation review found the home scope wrote the operator's own
~/.claude on nodes where the agent is the operator, and that text put
straight on the bus at the mesh or node scope still renders; record both,
the fix for the first, and what closes the second.
2026-10-09 09:43:03 +02:00
mesh-admin b43505d88b Merge pull request 'ADR 0270 and 0271 (accepted): a held delivery a later merge contains, or that moves nothing, ends by itself; a send held for the bus's step is told at once' (#241) from decision/0270-0271-held-deliveries-end-and-bus-step-said into main 2026-10-09 07:35:21 +00:00
mesh-admin 29bf2cae30 Merge branch 'main' into decision/0270-0271-held-deliveries-end-and-bus-step-said
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-09 07:30:09 +00:00
jochen 8a6f19e457 Issue 340: record the operator's decisions, and open 342 for passwords taken as settings
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Shared agent text is the terminal's until a surface of the operator's own
updates it, the wider controller rule stands, and a password is not a
setting: 342 names every module that takes one through a setting.
2026-10-09 09:29:39 +02:00
jochen cbd9f092d8 ADR 0270 and 0271 accepted: their home in designs 47 and 45, and the glossary's final states
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The operator accepted both on 2026-10-09. Design 47's state table gains covered and what ends a held
delivery by itself, and its group the wait for a composed check and the lift; design 45's signals table
gains S17 and its planned bus step says the wait is told at once. The glossary's delivery entry names its
six final states.
2026-10-09 09:24:30 +02:00
mesh-admin 9744ed7b9d Merge pull request 'Issue 336: a send held for the bus's planned step raises no condition, so the operator is never asked' (#228) from issues/336-a-send-held-for-the-bus-step-raises-nothing into main 2026-10-09 07:23:41 +00:00
mesh-admin a5656b9cce Merge pull request 'Record hygiene: resolve issues 310, 312, 313, 314, 318, 325, 327, 330 and 337, whose fixes are merged and live' (#239) from issues/hygiene-resolve-merged-fixes into main 2026-10-09 07:23:31 +00:00
jochen 79de22423d ADR 0270: how a failed new-module build is said, a one-machine module is never covered, and which gate tests the forge module
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Decision 1 named S6 for a failed new-module build; S6 is a lost build ask. Such a build is said by one
line in the controller's log until its module is assigned. A module on one machine never gets a 'rest'
step, so covered never reaches it. The forge module's tests run in the catalogue's TypeScript check, #151.
2026-10-09 03:24:10 +02:00
jochen 3d71532a3a Issue 341: a merge check judges the pull request's head, not what merging it makes
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Node-engine #66 passed both required statuses after #64/#65 had merged,
and its merge did not compile: each side added Applied.Declared. The
check clones the head alone and nothing marks a verdict stale when the
trunk moves; ADR 0237/0238 say the head, so the fix needs a decision.
2026-10-09 03:13:40 +02:00
jochen b04498c01f ADR 0270: the forge's ancestry decides covered, and say what covered means and does not (review of #241)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Merge order on a protected trunk is kept only as the first filter; the forge's compare, through a new
read-only tool, decides whether a later merge contains a held one. A merge held without a passing check is
covered only by a later delivery whose own check passed. Covered says a later merge that contains it was
delivered, not that the change runs: a revert in between stays possible. A delivery that only adds a module
is delivered whatever its build does, and covered wins over go when a person's walk fails between two
readings. The glossary gains covered beside superseded and delivered.

ADR 0271 names the bus upgrade as the mesh call and says S17 clears once the step sent the new bus.
2026-10-09 02:58:36 +02:00
jochen 1dbfb2d85a Issue 340: the whole layer of a module with a mergeable file is the terminal's, and the text still registrable is a risk said
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The review found that a mergeable file takes any key, not only those it
names, and that the text a session reads stays registrable through a verb;
say both, and the follow-ups the module took.
2026-10-09 02:55:23 +02:00
jochen 31e78d62d4 Name the pull requests that build ADR 0270 and 0271
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 02:35:05 +02:00
jochen 6163a74bb2 ADR 0270 and 0271 (proposed): a held delivery whose change runs ends by itself; a send held for the bus's step is told at once
Issues 319 and 316: fifteen held deliveries asked a person a question with no right answer, and stop,
the only way out, records a delivered change as an error. ADR 0270 delivers a merge that moves nothing at
once, adds the final state covered for a merge a later delivered walk of the same trunk carried, and lifts
a group hold once its composed check passed.

Issue 336: a send held for the bus's planned step raised nothing for 28 minutes. ADR 0271 adds row S17,
said at the first refusal for the operator with the verb, keeps S3 from calling it lateness, and has the
delivery plan say before the merge that a person's bus upgrade follows.

Each issue's diagnosis says what the fix does and how to verify it once delivered.
2026-10-09 02:34:18 +02:00
jochen 9bd0dcc611 Merge remote-tracking branch 'origin/issues/336-a-send-held-for-the-bus-step-raises-nothing' into decision/0270-0271-held-deliveries-end-and-bus-step-said 2026-10-09 02:31:05 +02:00
jochen 2953b8a8f4 Issue 340: an agent can plant a hook in every agent session on the mesh
mesh/repo-check pass: its merge-check.sh passed
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/delivery superseded: a newer head of the same pull request
Record the hole the review traced, why the module cannot tell who called,
which kinds run or are obeyed in a session and which stay registrable,
the fix in the controller and the module, what is registered today, and
that it must land before ADR 0266's agent account is switched on.
2026-10-09 02:30:11 +02:00
mesh-admin 171ac2a163 Merge pull request 'ADR 0269: what the mesh's own declaration stopped is started again when it goes' (#238) from decision/0269-what-the-meshs-own-declaration-stopped-is-started-again into main 2026-10-09 00:20:32 +00:00
jochen 120cdcd25a Issues 310, 312, 313, 314, 318, 325, 327, 330, 337: resolved, their fixes merged and seen live
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Each fix's pull requests are merged and delivered; each issue now says
what was read live on 2026-10-09 to show it, so the record stops
listing fixed faults as located.
2026-10-09 02:19:09 +02:00
jochen a11b14f7ae ADR 0269 accepted: note the narrowing on ADR 0118, and give it a home in the node-engine's design
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The operator accepted ADR 0269 on 2026-10-09. ADR 0118 gets the dated note the README asks for where a
later record narrows its mechanism. The node-engine's to-be design names the decision and says what it
changes, which the cycle check requires of an accepted record.
2026-10-09 02:17:32 +02:00
jochen 1a813ba313 ADR 0269: the -- check covers the calls in internal/system that name a service resource's unit
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 02:03:32 +02:00
mesh-admin 4b6642de5e Merge pull request 'Issue 339: the merge order against mesh-host #61, used-as-found as a behaviour change, the review's follow-ups' (#236) from issues/339-review-follow-ups into main 2026-10-08 23:55:39 +00:00
jochen 61ab74c70e ADR 0269: the catch-up happens when the timer's last run is more than a week back
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 01:54:51 +02:00
jochen 2e2be955e9 ADR 0269: say that a unit stopped by hand while held cannot be told apart, that a restored Persistent timer catches up at once, and what is left alone
Third review: a person's stopping a unit the mesh also held stopped leaves no trace, so it is started
again on removal; the home server's restored weekly timer would start a scrub of about 46 hours at once.
A masked or since-changed unit is left alone, and a failed restore waits.
2026-10-09 01:54:42 +02:00
jochen 68018c7c1b ADR 0269 (proposed): what the mesh's own declaration stopped is started again when it goes
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Unassigning the zfs module would leave the home server's pool with no scrub schedule: ADR 0118 never
starts a unit on the way out, so the distribution's timer the module held stopped would stay stopped.
The host now records what it declared beside what it found, and gives back only what the mesh's own
declaration stopped.
2026-10-09 01:45:40 +02:00
jochen 1d9d535912 Issue 339: an unmarked file counts as trusted, and the rollback hazard of the trusted field and its fix
mesh/repo-check pass: its merge-check.sh passed
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/delivery delivered
2026-10-09 01:42:51 +02:00
mesh-admin 5b0b550157 Merge pull request 'Issue 319: why no walk opened, and four held deliveries closed with stop' (#237) from issues/319-diagnosis-held-deliveries-closed into main 2026-10-08 23:39:02 +00:00
jochen a93ecf90ea Issue 319: record why no walk opened, and the four held deliveries closed with stop
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A new module counts as something to move, so its delivery is held; a later walk
reaches only the delivery whose merge is its commit. Four covered deliveries were
stopped before this issue was found, and their merged pull requests now say error.
2026-10-09 01:38:02 +02:00
jochen 5d054b722d Issue 339: the terminal's settings are derived from what a module serves and which files it trusts; a found directory is its own condition the gate never reads as a fault
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 01:24:14 +02:00
jochen 5fbd89c062 Issue 339: the gate reads a directory used as found as a wait for a person; the trust anchors are the terminal's
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 00:55:54 +02:00
mesh-admin e62ab24658 Merge pull request 'ADR 0268: the login shell's execute is withheld on the control-node until a call to it needs a person's approval' (#235) from decision/0268-the-login-shells-execute-is-withheld-on-the-control-node into main 2026-10-08 22:30:08 +00:00
mesh-admin ef2a38474f Merge pull request 'Research 039: a tool or verb that needs a person's approval' (#233) from research/039-a-tool-that-needs-a-persons-approval into main 2026-10-08 22:30:03 +00:00
jochen ab027691e1 Issue 339: name the merge order against mesh-host #61, record used-as-found as a behaviour change, and the review's follow-ups
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 00:28:55 +02:00
mesh-admin 0f02ac47d4 Merge pull request 'Issue 339: any caller of the settings verb could have the node-engine hand a system directory to an account' (#234) from issues/339-a-verb-can-place-a-directory-at-the-machines-own into main 2026-10-08 22:21:03 +00:00
jochen 7bd868786f ADR 0268: the login shell's execute is withheld on the control-node until a call to it needs a person's approval
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The operator's decision of 2026-10-09: execute runs any command as an
account that can become root without a person, so the control-node stops
offering it now, and every node marks it as needing approval once the
mark exists. To-be 41 gains WP7 for the seat and the shell module.
2026-10-09 00:12:53 +02:00
jochen ccb54450ea Research 039: the login shell's execute is the first case, and narrow read tools must come first or approvals become noise
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
2026-10-09 00:01:47 +02:00
jochen ac3a67ebab Issue 339: any caller of the settings verb could have the node-engine hand a system directory to an account
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
Located in the controller (places and accesses taken through every verb, no line-break check) and the node-engine (a found directory chowned as root, through a link too, and any path mounted). ADR 0266's boundary depends on the fix.
2026-10-09 00:00:04 +02:00
jochen c31a76cac5 Research 039: a tool or verb that needs a person's approval, so an agent may ask but cannot act alone
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-08 23:59:45 +02:00
mesh-admin e2de8811a3 Merge pull request 'ADR 0267: an artifact is rebuilt only when its build source changes, and a module moves only along build dependencies on artifacts' (#232) from decision/0267-a-module-moves-when-its-build-source-changes into main 2026-10-08 21:13:14 +00:00
jochen d2fe9bc870 Accept ADR 0267: the operator's word of 2026-10-08
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
Set its status to accepted and state the notes on ADR 0162, 0238 and 0236
as decided.
2026-10-08 23:11:35 +02:00
jochen 7d3f4407a7 ADR 0267 (proposed): an artifact is rebuilt only when its build source changes
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A merge to a shared repository moved every module that read it, in a gated
tier each (issue 338). The operator's rule maps a change onto the artifacts
whose build source it touches and adds only modules with a build dependency
on a changed artifact; sharing a repository is no dependency. Supersedes in
part ADR 0162 decision 1 (the packages kind) and ADR 0238 decision 3 (whole
repository and context reads); adds build source to the glossary.
2026-10-08 23:05:51 +02:00
mesh-admin d0be529194 Merge pull request 'Issue 337: the store's reader can read the controller's signing key (critical)' (#230) from issues/337-the-stores-reader-can-read-the-controllers-signing-key into main 2026-10-08 20:16:30 +00:00
jochen 7f78016f07 Issue 337: say that only what is gone is reported revoked, and that a grant the reader passed on is taken back with CASCADE
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 22:11:16 +02:00
jochen b17839a7db Issue 337 after review: say control-node, drop the store's operational settings, and match the fix's revoke-by-grantor and read-back
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
Adds what the fix cannot see (definer functions and views; the name check
as a backstop) and, as a follow-up, that the reader's read-only session is a
default a statement can turn off, so the missing write grants are the guard.
2026-10-08 22:02:30 +02:00
jochen 7ade322bcb Register issue 338: a module built from a shared repository moves on every merge to it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A controller merge walks three gated tiers because the route proxy and the
build seat are planned by repository, not by the paths their builds read,
and their programs carry the commit. Located; fix directions recorded.
2026-10-08 21:59:18 +02:00
mesh-admin 4955a663d3 Merge pull request 'Issue 335: a build that passed its gate was failed and put back by the first machine's later reports' (#227) from issues/335-a-passed-gate-was-failed-by-later-reports into main 2026-10-08 19:51:27 +00:00
jochen bbbaf943e8 Issue 335: resolved by mesh-controller#165 (8170fc5) with replay R335
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 21:49:00 +02:00
jochen 6c297214ad Issue 337: the store's reader could read the controller's signing key and every secret the store holds
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Opened and diagnosed: pg_read_all_data on the login the store seat's query
runs as. Says what the store can and cannot tell about whether the key was
read, why it should be rotated once the fix is taken, and that no rotation
mechanism exists yet. The fix is mesh-catalog PR 143.
2026-10-08 21:46:01 +02:00
mesh-admin a1c85c6de5 Merge pull request 'Design 52: until it is adopted, the workstation's fstab line still mounts at boot (review follow-up)' (#229) from fix/shares-review-followups into main 2026-10-08 19:37:03 +00:00
mesh-admin ebffbaa17e Merge pull request 'ADR 0264 and issue 330: a message a consumer gives up on is kept until a person delivers it again or drops it' (#223) from fix/330-a-message-given-up-on-is-kept into main 2026-10-08 19:26:57 +00:00
jochen 4dd52cace6 Design 52: say the workstation's fstab line still mounts at boot until it is adopted, and can hold the boot 30 s
mesh/delivery-group group fix/shares-review-followups delivered: every member is delivered
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
2026-10-08 21:14:34 +02:00
jochen 6fdaeb79ba Issue 334 and ADR 0264: an ask a seat gave up on can only be dropped for now
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
The review refused a grant over every seat's queue, and a copy published
again left the original stuck in it.
2026-10-08 21:08:12 +02:00
jochen b0986e2ae0 Issue 330: name the installer's user list among its fixes 2026-10-08 21:08:12 +02:00
jochen d1e0e121d6 ADR 0264 and issue 330: keep what a consumer gives up on until a person acts
Design 25 promised a dead-letter stream nothing built; a message a consumer
gave up on was lost after a week, silently once its condition cleared.
2026-10-08 21:08:12 +02:00
jochen e6f0e7e359 Issue 336: a send held for the bus's planned step raises no condition, so the operator is never asked
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
For 28 minutes on 2026-10-08 every send to the control-node waited for a bus upgrade only a person can start, and nothing but a walk's note said so. Located in the controller: the refusal is kept as the walk's note, S3 fires only past a 30-minute bound with generic words, and probe DB reads only a running step.
2026-10-08 20:58:44 +02:00
jochen f346efb938 Issue 335: leave fixed-by for the merge, and call the put-back refusal a guard
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
2026-10-08 20:49:40 +02:00
mesh-admin 4eb8be9e66 Merge pull request 'ADR 0263 (accepted), research 038, design 52: a share is a provision, a node mounts it on demand' (#221) from feat/mounts-module into main 2026-10-08 18:39:59 +00:00
jochen b409537dbc Issue 335: a build that passed its gate was failed and put back by the first machine's later reports
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Recorded so the gate's verdict is known to be final, and the replay R335
holds it.
2026-10-08 20:27:36 +02:00
jochen d05c9d7ca4 Name the safe order of taking shares into use without the retired word
mesh/delivery-group group feat/mounts-module delivered: every member is delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 20:22:05 +02:00
jochen bffd0f90d0 ADR 0263 and design 52 after review: what each export promises, per-node grants, refusals, not-built checks, and a read-only-first rollout 2026-10-08 20:21:48 +02:00
mesh-admin 0811b21be1 Merge pull request 'ADR 0265 and issue 327: a verb that reads is answered on the serving controller's own connection, and every connection says what it is' (#224) from fix/327-a-verb-reads-on-the-serving-connection into main 2026-10-08 18:21:28 +00:00
jochen 0a38d9b21d ADR 0265: a panicking verb answers, and refused logins are not a reconnect loop
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/327-a-verb-reads-on-the-serving-connection delivered: every member is delivered
mesh/delivery delivered
2026-10-08 20:04:19 +02:00
jochen c3a0f7a595 ADR 0265 and issue 327: answer read verbs on the serving connection and name every connection
Hundreds of short connections an hour, all named mesh-controller, hid any
client reconnecting in a loop.
2026-10-08 20:04:19 +02:00
mesh-admin 8665027873 Merge pull request 'ADR 0262: a left-out module's data is backed up as this controller reads it, and an unplaceable line is listed' (#226) from fix/0262-backup-as-read into main 2026-10-08 16:41:31 +00:00
jochen f30b2a3ae0 ADR 0262: note how a left-out module's data is backed up, as the controller now does it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The record said its data is still copied; the shipped follow-up backs it up as this controller reads
it and lists an unplaceable line rather than failing the machine.
2026-10-08 18:38:09 +02:00
jochen f4b79ba7c2 Design 52 says how the modules were built: the server's bucket carries each share's path, two root processes, settings default to none
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/mounts-module checking: 2 of 3 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
2026-10-08 18:37:15 +02:00
jochen 34899385ec Accept ADR 0263 with the operator's choices: one share per folder so backups are never exported, mesh machines only 2026-10-08 18:23:23 +02:00
mesh-admin 79bd9911e6 Merge pull request 'ADR 0262: a preference has a default in its manifest, and the operator's own value has none' (#216) from feat/setting-defaults into main 2026-10-08 16:18:53 +00:00
jochen 6a9bc3f4b3 ADR 0262: say what a left-out module loses, and how far an entry's unknown key is read
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The record claimed a left-out module kept what the machine holds for it and that an entry's unknown key
was read like a top-level one; neither is true of the merged controller.
2026-10-08 17:55:38 +02:00
jochen f09c9befdf ADR 0262: a module with a field the controller does not know is left out and raised, never read silently
Dropping an unknown key would run a module without something its manifest says. Also name the check by
what a key is about, narrow the listing to one machine, and say how to roll back below this record.
2026-10-08 17:55:04 +02:00
jochen de7aca4fa6 ADR 0262: one listing of every preference, and research 037 surveying the literals to convert
The operator asked for one place that lists every module's preferences and each machine's value, and
for a list of the values the catalogues hard-code that should become preferences.
2026-10-08 17:55:04 +02:00
jochen 0e5a5e2085 ADR 0262: record the lenient read of stored manifests and the marked defaults layer
A stored manifest read strictly would fail the whole catalogue after a controller rollback, and a node
called default would lose its layer. Also scope the default, retire tunable, and list the new checks.
2026-10-08 17:55:04 +02:00
jochen 417db4d837 ADR 0262: let a preference carry a default so a running module can gain a setting
A ${setting:} nothing set failed composition, and settings refused a key no file asked
for yet, so no order of changes could add a setting to a running module. A manifest may
now default a preference; the operator's own value still has none.
2026-10-08 17:55:04 +02:00
mesh-admin acc82aa5e4 Merge pull request 'Issue 332: the service manager could neither clear a failed unit nor say what wants it' (#222) from issues/332-the-service-manager-cannot-clear-or-explain-a-failed-unit into main 2026-10-08 15:52:54 +00:00
jochen fd6c452e61 Issue 332: add unlink-dangling and the glob refusal from the review
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
disable cannot remove a link whose unit file is gone, so the verb that
finds it needed one that removes it.
2026-10-08 17:40:00 +02:00
jochen 3edafd78ab Research 038, ADR 0263 and to-be 52: let the mesh own shares and mounts, so no node depends on a hand-written export or fstab line
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/mounts-module rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
2026-10-08 17:23:52 +02:00
jochen c3a20baece Issue 332: the service manager could neither clear a failed unit nor say what wants it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A leftover user unit stayed raised on the workstation: no seat verb showed
the dangling enable link that keeps asking for it, or reset its failed
record, so the only step left was a shell. Lands per ADR 0246.
2026-10-08 17:21:16 +02:00
mesh-admin 9fc09fe0d5 Merge pull request 'Issue 331: resolved, the sensors condition cleared live' (#220) from issue/331-resolved into main 2026-10-08 15:17:28 +00:00
mesh-admin c9b0e55a1f Merge pull request 'Issue 331: fixed-by and replay R331' (#219) from issue/331-fixed-by-and-replay into main 2026-10-08 15:17:25 +00:00
jochen 7d60650ae7 Issue 331: resolve it, now the sensors condition is seen to clear on the laptop
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 17:12:50 +02:00
jochen a97efa99f8 Issue 331: name the pull requests that fix it and its replay R331
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Status stays located until the sensors condition on the laptop is seen to clear.
2026-10-08 17:11:33 +02:00
mesh-admin 72e0a851cf Merge pull request 'Issue 331: a declared tool check is never asked on the live link' (#218) from issue/331-a-tool-check-is-never-asked into main 2026-10-08 15:06:55 +00:00
jochen 33b0ee3d53 Issue 331: state how the fix is proved, what CI still skips, and that the grants hold live
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 17:02:52 +02:00
mesh-admin 31664fcd80 Merge pull request 'Issue 325 and ADR 0261: an assignment made while its module's build runs is kept pending' (#214) from fix/325-assign-says-why-a-module-is-not-there into main 2026-10-08 14:58:04 +00:00
jochen 6dda1b1d77 ADR 0261: one condition per pending assignment, and a raised row kept until it clears
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The second review of mesh-controller#150 changed points 1, 6 and 7; say
what the code now does.
2026-10-08 16:46:42 +02:00
jochen 3a09387c8e ADR 0261: an assignment made while its module's build runs is kept pending
The review of mesh-controller#150 found the controller now makes an
assignment after the person's act, which no record decided. Decide it,
cite it from the glossary and to-be 45, and correct issue 325's account
of what is settled where and what is sent.
2026-10-08 16:46:40 +02:00
jochen 63754ca874 Issue 325: assign said no module of that name while the module's build ran
Register the symptom and the fix in mesh-controller#150 (replay R325 in
mesh-lab#68), and name the pending assignment in the glossary.
2026-10-08 16:46:40 +02:00
jochen 08e065fbb6 Issue 331: record why no declared tool check was ever asked on a live machine
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-08 16:45:21 +02:00
mesh-admin 1782f1ad74 Merge pull request 'Issues 326-330: research 036's live bus faults, measured again and diagnosed' (#217) from issues/036-bus-faults into main 2026-10-08 14:29:17 +00:00
jochen c64a7b3d4a Issues 326-330: register research 036's live bus faults, each diagnosed
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The bus's own numbers were unexplained and raised by nothing. Measured again
read-only: mesh-delivery rewrites unchanged deliveries (326), every controller
verb dials its own connection (327), API errors have no reader (328), event
consumers outlive their declaration (329), and design 25's dead-letter stream
was never built (330). The two slow consumers join issue 270, whose missing
system account they share.
2026-10-08 16:27:34 +02:00
mesh-admin 7d1e3822bf Merge pull request 'ADR 0260: three standing reads, drop-in directories are no module's, the lists grow only in the open' (#215) from fix/0260-path-check-facts into main 2026-10-08 14:27:19 +00:00
jochen e182e9ab12 ADR 0260: correct the count of standing reads, and say drop-in directories are no module's and the lists grow only in the open
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 16:12:54 +02:00
mesh-admin 91272a8e2c Merge pull request 'Research 036: everything moves over NATS — where the mesh still talks around the bus, and what a view onto the bus needs' (#213) from research/036-everything-over-nats into main 2026-10-08 14:12:02 +00:00
mesh-admin 9efa785f38 Merge pull request 'ADR 0260: a bar block shows a value its module keeps on the bus, and no module reads another's files' (#212) from feat/the-bar-takes-blocks into main 2026-10-08 14:09:26 +00:00
mesh-admin a95e8060c3 Merge pull request 'ADR 0257, issues 321-323, research 035: record the bases builds copy; what the pre-mesh images are' (#207) from decision/a-copied-base-is-recorded into main 2026-10-08 14:02:47 +00:00
mesh-admin dd316afa57 Merge pull request 'ADR 0254: correct what the fix covers after its review (progressive insight, issue 318)' (#210) from insight/0254-every-module-of-a-failed-send-gets-a-verdict into main 2026-10-08 14:02:21 +00:00
jochen 9afd6f8902 ADR 0260: say what review narrowed — one key per grant, per-machine state, wider path roots, a follower that speaks
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 15:54:45 +02:00
jochen 56e78612dc ADR 0257: the sweep after a build leaves an index for a confirmed collect
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Let go of alone, an index's platforms would stay for ever under a collected record
(re-review of #207).
2026-10-08 15:50:36 +02:00
jochen b7624cf023 ADR 0254 insight: state the two remaining costs, and point decisions 1 and 5 at their corrections
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 15:45:34 +02:00
jochen 5e9a27a6a3 Research 036: measure where the mesh talks around the bus, so views can be built from it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 15:28:57 +02:00
jochen 9b3ccfc1b4 ADR 0254 insight: a broken module goes back at once, and only a move that adds a group is excused, at a stated cost
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-08 15:28:31 +02:00
jochen 64ef5a0ac8 ADR 0254: correct what the fix covers after its review, as a progressive insight (issue 318) 2026-10-08 15:28:31 +02:00
jochen 427b078093 ADR 0260: a bar block shows a value its module keeps on the bus, and no module reads another's files
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The operator restated that modules talk over the bus; ADR 0255 had the bar print the
power module's file.
2026-10-08 15:24:04 +02:00
mesh-admin 4196b711f8 Merge pull request 'ADR 0256: one module reads a machine's hardware condition, and a fault is said through its health' (#206) from decision/hardware-sensors into main 2026-10-08 13:20:58 +00:00
mesh-admin f08342ad50 Merge pull request 'ADR 0258: name the mesh MCP server, not the retired console' (#209) from fix/needs-you-from-the-console into main 2026-10-08 13:20:45 +00:00
jochen df41dfff38 ADR 0256: say exactly how a SAS drive, a USB drive and a stuck read are treated
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The SAS sentence now holds for modes with spaces, a hung read is unhealthy in two to three minutes, not
one, every USB drive is told sat, and the limits of a stuck read are recorded.
2026-10-08 15:04:03 +02:00
jochen 39585e3bb2 ADR 0258: name the mesh MCP server, not the retired console
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/needs-you-from-the-console delivered: every member is delivered
The glossary retires "console"; the words an operator reads say where to act in
the mesh's own word.
2026-10-08 15:03:41 +02:00
mesh-admin f5f4eedf7a Merge pull request 'ADR 0254: a wait for a person's new login is not a gate's failure, and each module of a send keeps its own pass (issue 318)' (#204) from fix/318-a-wait-for-a-person-is-not-a-failure into main 2026-10-08 13:03:12 +00:00
mesh-admin fc9df13497 Merge pull request 'Issue 324 and ADR 0258: a desk click performs only an acknowledgement, an answer is a one-time ticket' (#208) from fix/notifications-after-review into main 2026-10-08 13:03:00 +00:00
jochen 0406373545 ADR 0256: one reader of the drives, and a stuck read is said
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
After the second review: smartd is dropped as a second reader that could wake or keep awake the drives,
a stuck read or a drive not looked at is unhealthy, a drive asleep for a day is HIGH and never unhealthy,
and a board chip's maximum says nothing of a fan. The never-woken and hourly claims now say exactly what
holds.
2026-10-08 14:59:12 +02:00
jochen 3bfedcfa15 ADR 0258 and issue 324: the live window, data loss never silenced from a click, and where to give what no notification can
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/notifications-after-review delivered: every member is delivered
The second review found the fix could not read what the live build kept, missed
quiet warnings known from history, and let a click silence data loss.
2026-10-08 14:56:48 +02:00
jochen b6690c38cf ADR 0257: platforms go only in a confirmed collect; say what stays, and what a rollback costs
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The sweep after a build is real collection with no dry run in front of it, so it lets an
index go alone (review of #207).
2026-10-08 14:53:04 +02:00
mesh-admin ef8b76461e Merge pull request 'ADR 0255: the bar takes blocks as data from the module that knows the hardware' (#205) from feat/the-bar-takes-blocks into main 2026-10-08 12:44:40 +00:00
jochen f5d08f4b6f Issue 324: name the pull requests that fix it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/notifications-after-review checking: 2 of 3 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
2026-10-08 14:40:30 +02:00
jochen dd149a72c0 Issue 324 and ADR 0258: a desk click performs only an acknowledgement
Review found that ADR 0253 let any process at the operator's session release,
stop or start a delivery in the operator's name, and that its tokens, fallback
words and quiet warnings failed on the paths that go wrong.
2026-10-08 14:40:02 +02:00
jochen 01e48196f7 Narrow ADR 0254 to what the walk path does, and say what a pending relogin excuses (issue 318)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/318-a-wait-for-a-person-is-not-a-failure delivered: every member is delivered
2026-10-08 14:38:08 +02:00
jochen e4299b5521 ADR 0255: say what review changed — if-capability on offered kinds only, unrenderable pieces left out and named, and why the sampler runs everywhere
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/the-bar-takes-blocks delivered: every member is delivered
2026-10-08 14:35:56 +02:00
jochen f63828ce1c ADR 0256: say exactly what raises a condition, and why a drive is read only hourly
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Rule 4 now matches the module after review: the NVMe temperature bit, old-age attributes, a fan's minimum
or alarm and a board chip's limits raise nothing; a stale watch does. The standby caveat is recorded: no
mesh tool sees a drive's spin-down timer, so spinning drives are read hourly.
2026-10-08 14:35:21 +02:00
jochen 781681a117 ADR 0254: a wait for a person's new login passes the gate, so one module's wait stalls no walk (issue 318)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/318-a-wait-for-a-person-is-not-a-failure checking: 3 of 4 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
2026-10-08 14:25:40 +02:00
mesh-admin 1c987e0df7 Merge pull request 'Issue 320 and ADR 0253: a condition says itself to the operator in plain words' (#203) from feat/plain-notifications into main 2026-10-08 12:20:39 +00:00
jochen 59bc551b5b ADR 0257, issues 321-323, research 035: record the bases builds copy, and say what the pre-mesh images are
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The store's tools counted 23.8 GiB nothing records: copied bases the sweep could never
let go of, platforms a let-go index left behind, and images from before the mesh. Package
retention read no lockfile, so it kept versions by age alone.
2026-10-08 14:08:45 +02:00
jochen 356c5f23f4 ADR 0253: say whether the operator is needed, keep quiet what needs nothing, answer from the notification
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/plain-notifications delivered: every member is delivered
The operator read the reworded popup and could not tell whether to act, and was
sent to open a session to act at all. The record now decides the verdict, the
quiet rule and the path by which a notification is answered.
2026-10-08 14:00:38 +02:00
jochen c8b6a4eea8 ADR 0256: one module reads the machine's hardware, so a failing drive or fan is raised, not found dead
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A machine's temperatures, fans and drive health were read by the laptop's vendor module only; the
machines' 20 drives were watched by nothing. The sensors module reads them on every machine, holds no
seat until a caller must reach it by role, and says a hardware fault through ADR 0240's health until the
machine judges its own hardware. To-be 48 gains §11 and phase G.
2026-10-08 13:48:52 +02:00
jochen 5852b66988 ADR 0255: the bar takes blocks as data from the module that knows the hardware
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/the-bar-takes-blocks rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
The operator chose a generic boundary for the bar: a block is said in terms no bar
owns and rendered by the seat's holder, placed only where the machine reports the
hardware, and generic hardware comes from the generic seat's module, not a vendor's.
To-be 42 records it; ADR 0208 points to it.
2026-10-08 13:45:27 +02:00
jochen fc3c42318f Issue 320: name the pull requests that fix it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/plain-notifications ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
2026-10-08 13:29:33 +02:00
jochen 7c49aa0ffc Issue 320 and ADR 0253: a condition says itself to the operator in plain words
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The operator could not read the mesh's notifications: they were the summary meant
for an agent, with ids, commits, keys and verbs, in UTC. The condition now carries
a headline, an explanation and a resolved line beside its summary.
2026-10-08 13:28:50 +02:00
mesh-admin 73fa97efde Merge pull request 'Issues 316-319: holds that never lift, holds walked past, and one module's wait stalling every walk' (#202) from issues/316-319-holds-and-gates into main 2026-10-08 11:27:49 +00:00
jochen aa221abfee Issues 316-319: holds that never lift, a hold a later merge walks past, and one module's wait stalling every walk
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Four delivery defects seen on 2026-10-08, each located, so their decisions can be made before code moves.
2026-10-08 13:23:49 +02:00
mesh-admin df0097245d Merge pull request 'Issue 314: a large answer was lost on its way back; issue 200 resolved by 265's fix' (#198) from issues/314-a-large-answer-was-lost-on-its-way-back into main 2026-10-08 11:06:34 +00:00
mesh-admin c3022d5e14 Merge pull request 'ADR 0251 and to-be 51: the registries say what they hold and keep what is named' (#200) from design/051-the-registries-keep-what-is-named into main 2026-10-08 11:06:17 +00:00
mesh-admin 7879ade66f Merge pull request 'ADR 0252: a module puts an account in a group, and the mesh says when a new login is needed' (#201) from decision/0252-a-module-puts-an-account-in-a-group into main 2026-10-08 11:05:12 +00:00
jochen f91735b629 Put the store's tools in a module beside it, since the store's module may not build (ADR 0251)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 12:08:02 +02:00
jochen 3449775cb6 ADR 0252: a module puts an account in a group, and the mesh says when a new login is needed
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Issue 247 left adding the operator's account to a daemon's group a sudo step by hand. Decide that
a module declares it with the user resource's groups, that the mesh gives back only what it added,
and that the node-engine says relogin needed; amend to-be 41 with WP6 and resolve the issue.
2026-10-08 12:04:50 +02:00
jochen f1e2ebce7a Record how the registries keep what is named, so nothing piles up unseen (ADR 0251, to-be 51)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-08 11:51:59 +02:00
mesh-admin f2201beb40 Merge pull request 'Issue 315: a failed unit of a module was never raised' (#199) from issues/315-a-failed-unit-was-never-raised into main 2026-10-08 09:40:28 +00:00
mesh-admin bd14e1b021 Merge pull request 'Issue 313: history adopted at the switch was held for a person; ADR 0250 retires it' (#197) from fix/313-retire-adopted-history into main 2026-10-08 09:36:53 +00:00
jochen 28aabd8621 Issue 315: say the node-engine's repository as an identifier
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 11:30:30 +02:00
jochen f78e21c5f9 Issue 314: a large answer was lost on its way back; issue 200 resolved by 265's fix
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
An answer over the bus's max_payload was refused inside the controller and
every caller timed out, while calls said answered. Diagnosed and proved (R314),
and told apart from issue 200, whose permission refusal issue 265 already fixed.
2026-10-08 11:30:10 +02:00
jochen 7121d0737e Issue 315: a failed unit of a module was never raised
Liveness judged only long-running resources, so a module whose daemon
is a package's unit failed at every login while its machine read
healthy, and a degraded service manager was said by nothing.
2026-10-08 11:30:08 +02:00
jochen 67bb28da4e Issue 313: history adopted at the switch was held for a person; ADR 0250 retires it and ends owed work the forge cannot do
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/313-retire-adopted-history stopped: a member was stopped
2026-10-08 11:20:44 +02:00
mesh-admin b11219e3de Merge pull request 'Issue 309: two order rules ordered one pair both ways; ADR 0249 gives them a precedence' (#194) from issues/309-two-order-rules-ordered-one-pair-both-ways into main 2026-10-08 09:11:45 +00:00
mesh-admin c6e76015b7 Merge pull request 'Issue 310: a pull request ran its own copy of its repository's check' (#196) from issues/310-a-pull-request-ran-its-own-copy-of-its-check into main 2026-10-08 09:10:27 +00:00
jochen a0765c0b13 Issue 310: a pull request ran its own copy of its repository's check
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
The build seat ran the merge-check.sh in the change's head, so a branch older
than the script, or one that gutted it, merged untested; and the media
catalogue did not require mesh/repo-check at all. Located, fixed on branches,
replayed as R310; the protection change awaits the operator.
2026-10-08 11:00:44 +02:00
mesh-admin c0662f1171 Merge pull request 'Issue 312: no tool said a machine's disk load' (#195) from issues/312-no-tool-said-a-machines-disk-load into main 2026-10-08 08:55:40 +00:00
jochen 7839703b60 Issue 312: no tool said a machine's disk load, so issue 306 could not measure its disk
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 10:48:44 +02:00
jochen a3bedeb227 Issue 309: two order rules ordered one pair both ways; ADR 0249 gives them a precedence
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A group of the controller, the node-engine and the lab, each ready, was
refused as a cycle: built by put the controller's member first, engine
before controller the node-engine's. ADR 0249 ranks the rules (declared,
then built by and version skew, then engine before controller) and
supersedes ADR 0239's sentence that made a declared order against an
inferred one a cycle; design 47 says the precedence. Resolved by
mesh-controller #134 and mesh-catalog #119, replay R309 (mesh-lab #64).
2026-10-08 10:46:34 +02:00
mesh-admin 47aaae98b1 Merge pull request 'Issue 311: the media catalogue's check ran no tests for its TypeScript modules' (#193) from issues/311-the-media-catalogues-modules-ran-no-tests into main 2026-10-08 08:46:10 +00:00
jochen c67ceacccf Issue 311: the media catalogue's check ran no tests for its TypeScript modules
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
Its check declared only Go, so ten modules merged with nothing of their own run, and a
Go module without tests would have passed too.
2026-10-08 10:41:31 +02:00
mesh-admin fcb76ca29d Merge pull request 'Issue 306: a check on the control node ran past its timeout' (#191) from issues/306-a-check-on-the-control-node-ran-past-its-timeout into main 2026-10-08 08:34:59 +00:00
mesh-admin 37e82fb9fc Merge pull request 'Issues 307, 308: the lab's beds kept the bus and runtime the mesh had left; a lab unit test's verdict depended on the machine' (#192) from issues/307-308-the-labs-beds-and-a-host-dependent-test into main 2026-10-08 08:34:52 +00:00
mesh-admin 879b183268 Merge pull request 'Issue 305: a recheck left the old verdict standing' (#190) from issues/305-a-recheck-left-the-old-verdict-standing into main 2026-10-08 08:34:30 +00:00
mesh-admin 57b24738f3 Merge pull request 'Issue 146: the enrolment-time consumer is answered by issue 208; design 08 names the join's code' (#189) from issues/146-the-enrolment-consumer-and-the-tunnel-join into main 2026-10-08 08:24:28 +00:00
jochen 9c030a714a Issues 307, 308: the lab's beds kept the bus and runtime the mesh had left; a unit test's verdict depended on the machine
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Registered so the lab's state on 2026-10-08 is answered where the mesh's faults are, not only in a pull request.
2026-10-08 10:23:34 +02:00
jochen db0fea6067 Issue 306: a check on the control node ran past its timeout
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Store-bound packages ran five to seven times slower there while the rest
ran alike: the throwaway store flushes to a disk the mesh's own data keeps
busy. And a holder recreated mid-check left its store and bus running.
2026-10-08 10:21:49 +02:00
jochen 573a44a33d Issue 305: a recheck must put the merge check back to pending, or the old verdict merges
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
2026-10-08 10:12:52 +02:00
mesh-admin 0abca926ea Merge pull request 'ADR 0217, to-be 44: no change to a machine takes effect unseen' (#97) from feat/no-change-takes-effect-unseen into main 2026-10-08 00:06:01 +00:00
mesh-admin af398aef7b Merge pull request 'Issues 303, 304; 240 resolved, 242 located' (#94) from issues/245-246 into main 2026-10-07 23:55:47 +00:00
jochen 43bffdc0c3 Issue 146: the enrolment-time consumer is answered by issue 208's assertion; design 08 names the join's code
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
The branch building ADR 0169 carried a fix making a node's declaration
consumer as it enrols. The controller already asserts every node's
consumer before each send, and the self-check's healer asserts a missing
one again, so the fix is dropped rather than merged and the record says
why. The installer now places the bus's accounts at genesis.
2026-10-08 01:53:07 +02:00
jochen e3caa4e1d4 ADR 0217, to-be 44: no change to a machine takes effect unseen
mesh/delivery-group group feat/no-change-takes-effect-unseen delivered: every member is delivered
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
Two incidents in two days had one shape: a change took effect that nobody saw first (issues 241
and 304). Three guards where the change is made — a settings layer read before it is replaced, a
push that says what it will change, a running module's data move acknowledged — each silent when
nothing is at stake. Accepted by the operator on 2026-10-08.

To-be 44 is in progress in mesh-controller (feat/no-change-takes-effect-unseen), migration 0078,
fitted onto what the controller does since: a whole-mesh push that says so and leaves a machine
waiting for a gate, a person's push that says what it recreates (ADR 0242), a named push's cascade.
The decision index is generated (ADR 0248), so no line is added to its README.
2026-10-08 01:45:49 +02:00
jschoubben 67599df951 Issues 303, 304; 240 resolved, 242 located
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
303: a media server's previews were reached through a link its container never mounted — fixed by
mounting them. 304: settings set replaces the whole layer with nothing to read it first and no
history. 240 is fixed by mesh-controller#47; 242 has backups running and records what the rollout
taught. (First opened as 245 and 246; main has since taken both numbers, and 302 was the last.)
2026-10-08 01:45:39 +02:00
mesh-admin 08a874b55f Merge pull request 'Triage issues 279-298: resolve the fixed, with replays R292 and R298' (#188) from issues/triage-279-298 into main 2026-10-07 23:41:52 +00:00
mesh-admin 78fd323df3 Merge pull request 'Triage issues 228-278: close the fixed, re-check the rest' (#187) from issues/triage-228-278 into main 2026-10-07 23:41:48 +00:00
mesh-admin ed24d950db Merge pull request 'Issues 092–208 triaged on 2026-10-08: 13 resolved, 1 wontfix, the rest re-checked' (#186) from issues/triage-092-208 into main 2026-10-07 23:41:45 +00:00
jochen 64e97d0de1 Triage issues 279-298: resolve the fixed with replays R292 and R298
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
292 and 298 resolve with replays now registered in mesh-lab. The other fixed
core issues say why no replay can be laid over the commit before their fix. The
rest are re-checked against main.
2026-10-08 01:31:13 +02:00
jochen 2d18fa3380 Triage issues 228-278: close the fixed, re-check the rest
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Each issue is resolved with its fix and a replay or the reason none exists, or
re-checked against main and the live mesh on 2026-10-08.
2026-10-08 01:31:10 +02:00
jochen 623fccee23 Triage issues 092-208: close the fixed and the obsolete, re-check the rest
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The operator asked on 2026-10-08 for the deprecated issues to be gone. In hq an issue
is closed, never deleted, so each one is resolved with its fix, closed wontfix with a
reason, or re-checked against main and the live mesh.
2026-10-08 01:31:06 +02:00
mesh-admin 990414c9b8 Merge pull request 'Ignore Python's cache folder' (#185) from chore/ignore-python-cache into main 2026-10-07 23:05:54 +00:00
jochen 5d4e427117 Ignore Python's cache: running the checks wrote it, and one file had been committed by mistake
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-08 01:04:03 +02:00
mesh-admin c6ff3b65b6 Merge pull request 'Issues 301, 302: a push of recorded builds is no repair; the lab's Go replays are checked in Go' (#184) from issues/301-302-a-push-of-recorded-builds-and-the-labs-go into main 2026-10-07 22:26:27 +00:00
jochen 7fde8f4bbe Issues 301 and 302: a push of recorded builds is no repair, and the lab's Go is checked in Go
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
301: S15 counted the operator's pushes of recorded builds, the word ADR 0242
asks for, as repairs and wanted a healer for three causes. 302: the lab's
replays register was never compiled before a merge, because its check ran in
the TypeScript toolchain. To-be 45 §7 and §9 say how each is now decided and
checked.
2026-10-08 00:17:14 +02:00
mesh-admin 69ab2fd7f7 Merge pull request 'Issues 296, 299, 300: resolved, each with its replay' (#183) from issues/296-299-300-resolved into main 2026-10-07 21:55:20 +00:00
jochen 787f428e7e Issues 296, 299, 300: resolved, each with its replay proved to fail before its fix
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 23:44:47 +02:00
mesh-admin ded2173308 Merge pull request 'ADR 0248: the decision index is generated when it is read, and never stored (issue 297)' (#182) from decision/generated-decisions-index into main 2026-10-07 21:44:14 +00:00
jochen f4a76ca291 Issue 297: resolved by hq PR #182 and mesh-catalog PR #115
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 23:39:30 +02:00
jochen 55637ecb95 ADR 0248: the decision index is generated when it is read, so two open decisions no longer conflict 2026-10-07 23:38:34 +02:00
mesh-admin eb54474519 Merge pull request 'Issue 300: a new module is not built at its merge' (#181) from issues/300-a-new-module-is-not-built-at-its-merge into main 2026-10-07 21:01:41 +00:00
mesh-admin 2adbfed90f Merge pull request 'Issue 299: an optional verb makes a holder look silent' (#180) from issues/299-an-optional-verb-makes-a-holder-look-silent into main 2026-10-07 21:01:34 +00:00
jochen aaaef91b7b Issue 300: say delivery plan, the glossary's word
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 22:40:17 +02:00
jochen 9b573f4ca0 Issue 300: a new module is not built at its merge
The change plan said a merge adding a module builds it; the merge built
nothing, so the module stayed unregistered until a hand build.
2026-10-07 22:39:56 +02:00
jochen 6006754172 Issue 299: an optional verb makes a holder look silent
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
D3 raised node-uplink silent on every machine once the seat gained verbs its
holders are not yet required to serve, against ADR 0246's step 1.
2026-10-07 22:29:04 +02:00
mesh-admin dda850863f Merge pull request 'ADR 0247: a machine with a VPN client routes names by domain, through a resolver of its own' (#179) from decision/0247-split-dns-on-a-machine-with-a-vpn-client into main 2026-10-07 19:46:08 +00:00
jochen 6373ca815b ADR 0247: say exactly where the VPN's domains go and do not
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 21:35:40 +02:00
jochen 7e9d0dfe7b ADR 0247: say what is to confirm live about resolved and the resolver file, not a guess
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
2026-10-07 21:35:21 +02:00
jochen 478a198e9a ADR 0247: a machine with a VPN client routes names by domain, through a resolver of its own
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Research 033 measured the laptop's VPN client and the mesh overwriting each
other's resolver file in nine of nine sessions, each time cutting off one
kind of name. The operator chose a resolver module on a node seat of its
own, installed only where something requires split-dns, with the VPN
client's module carrying the adapter. Graduates research 033 into to-be 50,
and amends connectivity §2, to-be 48 §10, the seats and the glossary.
2026-10-07 21:33:48 +02:00
mesh-admin 9b6479f78f Merge pull request 'Issue 297: two open decisions always conflict' (#177) from issues/297-two-open-decisions-always-conflict into main 2026-10-07 19:21:55 +00:00
mesh-admin 0bd12fc777 Merge pull request 'Issue 298 and ADR 0246: a seat's new verb is promised before it is required' (#178) from issues/298-a-new-seat-verb-deadlocks into main 2026-10-07 19:21:52 +00:00
mesh-admin 3083283e17 Merge pull request 'words.py: a retired word's plural is the word' (#176) from fix/words-match-plurals into main 2026-10-07 19:21:34 +00:00
jochen 91daabaa97 Say the uplink verbs shipped optional, not staged: a progressive insight in ADR 0241, corrected in to-be 48
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The controller that merged (mesh-controller #116) dropped the staged seat and
marked both verbs optional, stored in the seat's row and read back optional.
2026-10-07 21:18:11 +02:00
jochen 4b36234c9b Reword the hosts file in to-be 36, found by the plural on the rebase onto main
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/words-match-plurals delivered: every member is delivered
2026-10-07 21:14:43 +02:00
jochen 350f7dcdc2 Hold a retired word's plural to the word, so alerts and rollouts no longer pass
words.py matched a retired word only when nothing followed it, so the
plural of every retired word passed. The pattern now takes s or es on the
last word; 14 uses in 9 documents are reworded, two of them 'the hosts'
for the node-engines.
2026-10-07 21:14:36 +02:00
jochen e6a17b8bf6 Issue 298 and ADR 0246: a seat's new verb is promised before it is required
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Design 33 said a seat's verbs change additively, and section 3 made each
one a condition of holding, so a verb added in the controller and served
from the catalogue could land in neither order. It happened three times on
2026-10-07. Section 7 now says the three steps and how each is checked.
2026-10-07 21:14:20 +02:00
jochen 57f95fc6bb Issue 297: every open decision conflicts with the next one to merge
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The written decision index is the one file every decision PR edits, at the
end of its topic, so on 2026-10-07 each merge forced the others to rebase
and be checked again. The diagnosis weighs four fixes and recommends
generating the index on read.
2026-10-07 21:11:42 +02:00
mesh-admin 921ff3e6c4 Merge pull request 'ADR 0245: a verb says what it replaces, and the agent is guarded from working round the mesh' (#173) from decision/0242-a-verb-says-what-it-replaces into main 2026-10-07 19:10:00 +00:00
mesh-admin 52ecd8bfd5 Merge pull request 'Issue 296: a plan's clock restarted at every save' (#175) from issues/296-a-plans-clock-restarted-at-every-save into main 2026-10-07 19:04:58 +00:00
jochen 6cacf772dc ADR 0245: a verb says what it replaces, and the agent is guarded from working round the mesh
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The agent reached machines over ssh because search never found the verbs by the commands it knew.
Records the three rules (generated instead-of table, search by replaced command, the shell guard
with the operator's recorded override) and how each is checked; to-be 36 names it.
2026-10-07 21:04:24 +02:00
mesh-admin ede4b29cd1 Merge pull request 'ADR 0241 and to-be 48 §10: a machine says how its network is, and an outside writer of a mesh file is a finding' (#168) from decision/0241-a-machine-says-how-its-network-is into main 2026-10-07 19:04:09 +00:00
jochen 6caa471ed0 Issue 296: the refused step logged and saved on every tick
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 20:47:09 +02:00
jochen c462837ff2 ADR 0241's design: name the node-engine's repository as code (ADR 0244)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 20:33:23 +02:00
jochen fe232388a0 ADR 0241 rule 8: the uplink seat answers what the machine resolves through
A finding about a machine's names needs a way to look further that is not
a terminal on the machine; the uplink seat's holders are where that is.
2026-10-07 20:33:23 +02:00
jochen a22a1661e1 ADR 0241 and to-be 48 §10: a machine says how its network is, and an outside writer of a mesh file is a finding
A VPN client rewrote the laptop's resolver file and every mesh name failed
while each module read healthy: nothing judged the machine under them.
2026-10-07 20:33:23 +02:00
mesh-admin f6aa1ced5e Merge pull request 'Issue 295 and ADR 0242 (proposed): a recorded build moves only by a person's push, and a send says what it recreates' (#171) from issues/295-a-recorded-build-was-carried-by-a-plans-send into main 2026-10-07 18:33:14 +00:00
jochen 998980bbda Issue 295: use the glossary's words (ADR 0244)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 20:24:59 +02:00
jochen be8ea16d28 Issue 295 and ADR 0242: a recorded build was carried by a plan's send, and mail went down with a health check
The plan delivering the first health declarations recreated the database and
the document store (record) and nine of mail's containers at once. Record
held only against a person's push to another machine; it now holds against
every send but a person's, and a send says what it recreates.
2026-10-07 20:24:24 +02:00
mesh-admin 4556d37370 Merge pull request 'ADR 0244: the mesh in domains, one word per thing, checked (graduates research 034)' (#174) from decision/0244-the-mesh-in-domains into main 2026-10-07 18:23:58 +00:00
jochen fbc6405f1e Issue 296: a plan's clock restarted at every save
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
plans counted a plan's time from its last save, and every advance saved it, so a queued build read
as seconds old and was never late; located in the controller, fix in mesh-controller PR #119.
2026-10-07 20:08:03 +02:00
jochen 008014591b Do not track the word check's compiled cache
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 20:07:33 +02:00
jochen 051696e107 Retire "the host" in hq prose only: in the tools it is also an SSH server's word
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-07 20:02:03 +02:00
jochen a28da1734b Graduate research 034: the mesh in domains, one word per thing, checked
ADR 0244 sorts the mesh's concepts into ten domains (ADR 0006's contexts
carry over as domains), keeps machine and node as distinct words, and
makes the glossary the authority with every retired word on its
replacement's Not: line. To-be 49 draws the domains; the glossary is
reorganised by them, its two contradictions removed and the missing
words added. words.py now fails on a retired word in running prose and
on a word defined twice, so the rule is enforced rather than believed;
the 63 documents it failed on are reworded here, and research 034 is
kept as the record of the words it studied.
2026-10-07 19:58:39 +02:00
mesh-admin 476558c687 Merge pull request 'ADR 0243: the agent module removes a home item it did not place only on the person's word, and keeps a copy' (#172) from decisions/0243-the-agent-module-removes-a-home-item-on-the-persons-word into main 2026-10-07 17:48:15 +00:00
mesh-admin d96644e827 Merge pull request 'Issue 294: a quoted value given to the controller's command line cannot hold a quote' (#169) from issues/294-a-quoted-value-cannot-hold-a-quote into main 2026-10-07 17:48:03 +00:00
jochen 9ab4ca66ca ADR 0243: the agent module removes a home item it did not place only on the person's word, so the old rule files can leave without a remote shell
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 19:43:55 +02:00
jochen f2abbb042c Research 034: the mesh in domains
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery stopped: the pull request closed unmerged
The operator asked for one language per domain because the same concept
keeps arriving under different words. Inventory the words in use, test a
domain split, list the clashes with evidence, and describe how the
glossary rule would be checked.
2026-10-07 19:37:44 +02:00
jochen 725ad51606 Issue 294: a quoted value given to the controller's command line cannot hold a quote
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 19:23:08 +02:00
mesh-admin 79cadf6c96 Merge pull request 'Research 033: split DNS with a VPN client' (#167) from research/033-split-dns-with-a-vpn-client into main 2026-10-07 16:54:28 +00:00
jochen 0ab47b9417 Research 033: split DNS with a VPN client
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The laptop's VPN client replaces the mesh's resolver file on every connect and
the node-engine writes it back minutes later, so either the mesh's names or the
corporate ones fail for whole sessions. Measure how the client sets DNS and
weigh how names can be routed by domain without breaking ADR 0223's one-answer
rule.
2026-10-07 18:38:49 +02:00
mesh-admin 30d78ec9a2 Merge pull request 'To-be 48 Phases B to E are built: what each chose, and what is still to read on the live mesh' (#166) from feat/a-module-says-how-it-is-healthy-b-to-e into main 2026-10-07 16:28:45 +00:00
jochen e453da3a3b To-be 48 Phases B to E are built: what each chose, and what is still to read on the live mesh
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 16:17:57 +02:00
mesh-admin 8ff22e4d9b Merge pull request 'Issues 292-293: a drill counted as a repair; a warning on a required check blocked the merge' (#165) from issues/292-293-a-drill-and-a-warning into main 2026-10-07 12:05:42 +00:00
jochen 9cf839c977 Issues 292-293: a drill counted as a repair, a warning on a required check blocking a merge
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 13:56:39 +02:00
mesh-admin eef2c5e082 Merge pull request 'Issues 290-291: a delivery adopted while it waited never got its check; a planned maintenance window failed the applies that met it' (#164) from issues/290-291-what-waits-was-never-asked into main 2026-10-07 11:20:21 +00:00
jochen 553d81be60 Issues 290-291: a delivery adopted while it waited never got its check; a planned maintenance window failed the applies that met it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 13:16:55 +02:00
mesh-admin f8189da489 Merge pull request 'Issue 289: a verb answered during a handover could not run its own build' (#163) from issue/289-a-verb-answered-during-a-handover into main 2026-10-07 00:40:22 +00:00
mesh-admin 781f55329d Merge pull request 'To-be 48 Phase A is being built: in-progress, its code named, what the build chose' (#162) from feat/a-module-says-how-it-is-healthy into main 2026-10-07 00:39:41 +00:00
jochen 0ebfdef50d Issue 289: name the pull requests that fix it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 02:29:32 +02:00
jochen dea72dc4fc To-be 48 Phase A is being built: name its code and say what the build chose
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/a-module-says-how-it-is-healthy delivered: every member is delivered
2026-10-07 02:28:18 +02:00
mesh-admin 7d57ace896 Merge pull request 'Issues 285, 286, 287, 288: the merge gate judged nothing; the seat's check disagreed with agents' machines; a seat and a module of one name were unreachable; the facts still name the installation' (#161) from issues/282-284-the-merge-gate-judged-nothing into main 2026-10-07 00:28:01 +00:00
jochen fd0bb826b3 Issue 289: a verb answered during a handover could not run its own build 2026-10-07 02:14:08 +02:00
jochen a9f396a0a0 Renumber the gate's issues 285-287 (282 was taken on main); issue 288: the facts snapshot still names the installation
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 02:04:46 +02:00
jochen 41a0e77748 Issues 282-284: the merge gate judged nothing, the seat's check disagreed with agents' machines, a seat and a module of one name were unreachable 2026-10-07 01:59:45 +02:00
mesh-admin 35d81e962e Merge pull request 'Issue 282: a secret on a command line is kept in the runtime's events' (#160) from issue/282-a-secret-on-a-command-line into main 2026-10-06 23:40:55 +00:00
jochen 124943bdc3 Issue 282: a secret on a command line is kept in the runtime's events
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The mosquitto module passed its broker's admin password to every docker
exec as an argument, which the container runtime records in its events.
Records the cause, the other modules found doing the same, the fix
proposed in mesh-catalog #100, and where the leaked value went.
2026-10-07 01:38:37 +02:00
mesh-admin e23efb63a9 Merge pull request 'ADR 0240 and to-be 48: a module says how it is healthy, and the node-engine judges it' (#159) from decision/0240-a-module-says-how-it-is-healthy into main 2026-10-06 23:35:39 +00:00
jochen 0bf579d99c ADR 0240 and to-be 48: a module says how it is healthy, and the node-engine judges it
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The gate judged a module by what the mesh sees from outside, so a crash loop and an
eleven-hour silent web app passed it. Graduates research 032 on the operator's word.
2026-10-07 01:33:06 +02:00
mesh-admin 2e8b7321c7 Merge pull request 'Research 032: a module says how it is healthy — evidence, options, recommendation' (#158) from research/032-a-module-says-how-it-is-healthy-findings into main 2026-10-06 23:18:40 +00:00
jochen ef6ab814b0 Research 032: measure module health on the live mesh and propose a decision
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Liveness is judged nowhere and readiness cannot be declared; the evidence,
options and a graduation-ready recommendation say how a module states it.
2026-10-07 01:18:00 +02:00
mesh-admin 4405a3048c Merge pull request 'ADR 0239: a waiting walk is said by the controller; Phase B built' (#157) from design/0239-a-waiting-walk-is-said into main 2026-10-06 22:47:15 +00:00
jochen 1fcd238cdf ADR 0239: a waiting walk is said by the controller; Phase B built
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
A walk mesh-delivery never lets go waited for ever with nothing open; the
controller now says it itself (S16), and the delivery's own stalls are its
conditions too (D14, H2 through close).
2026-10-07 00:14:09 +02:00
mesh-admin c51a3da5d5 Merge pull request 'ADR 0239: a delivery is owned by mesh-delivery and runs from commit to delivered' (#156) from design/0239-a-delivery-is-owned-by-mesh-delivery into main 2026-10-06 22:07:18 +00:00
jochen e1b95d09cd ADR 0239 and to-be 47: as built — registered at the walk's start, the forge asked through its tools
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/delivery delivered
A build registered before its turn is carried by the next send to its
machines, so a published delivery asks nothing until its walk starts; the
rows the build needed (appeared, adopted, held on no walk, held then go)
and Phase B's verbs-without-probe are now said.
2026-10-06 23:58:29 +02:00
jochen 16b187d4c3 ADR 0239: a delivery is owned by mesh-delivery and runs from commit to delivered
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: the change touches no module of the mesh's graph
One owner for one commit's journey, so 'did my change go out' is answered by
one module instead of five records joined by hand; delivery groups make the
cross-repository order the mesh enforces instead of the person merging.
2026-10-06 23:18:31 +02:00
mesh-admin 7216ffc825 Merge pull request 'ADR 0238: a commit is the build at hand — one commit, one change plan, checked off the trunk and published only on it' (#155) from design/0238-one-commit-one-change-plan into main 2026-10-06 21:00:50 +00:00
jochen 2221be11a6 ADR 0238: a commit is the build at hand — one commit, one change plan, checked off the trunk and published only on it
mesh/merge-gate the mesh is checking this head against every machine
mesh/delivery delivered
Turning the merge check on for every repository showed it asked the wrong questions: a
repository chose whether it was checked, the gate mapped a change onto modules its own
way, the shared-code rule rebuilt 103 modules for a root script, and nothing kept a
commit off the trunk from becoming a module's version. Records the operator's decisions,
narrows ADR 0237 decision 4, revises to-be 45 §9 and Phase 5 and to-be 30, closes issue
280's left-open, and gives this repository its own merge-check.sh.
2026-10-06 22:54:09 +02:00
mesh-admin 9bd54ee05d Merge pull request 'Issue 281: a tier sent one module at a time blamed a module for its machine' (#154) from issues/281-a-tier-sent-one-module-at-a-time-blamed-a-module-for-its-machine into main 2026-10-06 20:26:05 +00:00
jochen 0555508020 Issue 281: a tier sent one module at a time blamed a module for its machine
mesh/merge-gate the mesh is checking this head against every machine
mesh/delivery delivered
2026-10-06 22:25:22 +02:00
mesh-admin 76c00c15e2 Merge pull request 'Issue 280: a rebuild of an unchanged source was read as a new bus' (#153) from issues/280-a-rebuild-of-an-unchanged-source-was-read-as-a-new-bus into main 2026-10-06 20:12:06 +00:00
jschoubben 897bcce502 Issue 280: a rebuild of an unchanged source was read as a new bus
mesh/merge-gate the mesh is checking this head against every machine
mesh/delivery delivered
An image is not byte-reproducible, so ADR 0236's no-move rule never held
for one; the rule now also reads a build's source. Progressive insight on
ADR 0236 says what the rule assumed and what stands.
2026-10-06 22:11:27 +02:00
mesh-admin 163b4f6c48 Merge pull request 'Issue 279: a folder cannot be owned by the account a module runs as' (#151) from issues/279-a-folder-cannot-be-owned-by-the-account-a-module-runs-as into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 19:28:46 +00:00
mesh-admin ca86423664 Merge pull request 'ADR 0237, to-be 45 Phase 5: a change is judged against the mesh that runs before it merges' (#152) from feat/checks-before-merge into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 19:19:22 +00:00
jochen d245df7dea ADR 0237, to-be 45 Phase 5: a change is judged against the mesh that runs before it merges
The operator approved Phase 5. Decides what the design left open: the build seat runs the
merge check, the facts live in the artifact store, the merge gate composes the mesh as it is
and with the change and judges only what the change adds, a replay lives where its incident
is, and the controller's tests run a bus of their own at the mesh's release. A core issue now
resolves only with a replay or a stated reason, checked by cycle.py.
2026-10-06 21:10:54 +02:00
jochen 5e2b520307 Issue 279: a folder cannot be owned by the account a module runs as 2026-10-06 20:57:38 +02:00
mesh-admin be0754ec7f Merge pull request 'Research 032: a module says how it is healthy' (#150) from research/032-a-module-says-how-it-is-healthy into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 18:52:42 +00:00
jochen 5e54632626 Research 032: a module says how it is healthy
The release gate judges a module from outside (applied, no new condition,
tools answer); a container that restarts in a loop or a service that fails
its own work passes it. Investigate a health declaration in every manifest.
2026-10-06 20:50:25 +02:00
mesh-admin 68e432ec0b Merge pull request 'Issue 278: a module held by no machine was read as shared code; ADR 0236's open question answered' (#148) from issues/278-a-module-held-by-no-machine-was-read-as-shared-code into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 18:28:48 +00:00
mesh-admin 578e4ce8b3 Merge pull request 'To-be 45 §7: a hand act a person decides by design is no repair, by the verb that recorded it' (#149) from design/45-s15-a-persons-decision-is-no-repair into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 18:14:48 +00:00
jochen 2d56eae2f1 To-be 45 §7: a hand act a person decides by design is no repair, by the verb that recorded it
Planned bus upgrades and rotations after one leak raised healer-wanted,
because the exemption was keyed on two causes. Progressive insight: the
exemption is read from the verb table; the decisions stand.
2026-10-06 20:14:09 +02:00
jochen 0333aac134 Issue 278: a module held by no machine was read as shared code
The 103-module rebuild ADR 0236 put down to the build agent came from a file of the catalogue's
reference module, read as shared because its definition was not in the merge. Records the issue,
corrects ADR 0236's open question as a progressive insight (the tiering was right; the build agent
never widens a plan) and amends to-be 30's rule for what a merge changed.
2026-10-06 19:57:21 +02:00
mesh-admin 62d4b1e5d4 Merge pull request 'ADR 0236, to-be 45 Phase 4: a build is judged on its first machine and put back by something other than itself' (#146) from feat/core-upgrades-that-roll-back into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 17:15:09 +00:00
jochen 4ee06bd99f ADR 0236: no build reaches a machine without a gate, and a release plan walks what waits
The coordinator's review: at the switch to roll, every send would carry the old default's
backlog unjudged. Measured: 88 of the 103 rebuilt modules were byte-identical, and no
build waited on any machine.
2026-10-06 19:12:51 +02:00
jochen 301c551888 ADR 0236: no send reaches the bus's machine while a new bus build waits
Found live the same day: a plan's send of the catalogue carried the bus's rebuilt image
to the control node and restarted the bus unasked.
2026-10-06 19:12:51 +02:00
jochen 235330ce00 ADR 0236, to-be 45: a build is judged on its first machine and put back by something other than itself
Phase 4 of to-be 45, started by the operator: the core's health as probes, a gate on
every plan's first machine, the rollback there once per build, the witness's contract
with the host, the bus as a step a person starts, and — with those in place — rolling
out as the default, keeping record where a module says why. 47 pushes by hand in one
day are what it ends.
2026-10-06 19:12:51 +02:00
mesh-admin cfac4ac825 Merge pull request 'Issue 277: one unanswered question was an urgent alert nobody could read' (#147) from issue/277-one-unanswered-question-was-an-urgent-alert-nobody-could-read into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 16:47:41 +00:00
jochen 9092cbda38 Issue 277: one unanswered question was an urgent alert nobody could read
Record the single-sample flaw found across the probes and watchdogs, and
the summaries that carried addresses and paths past the operator channel's
content rule; amend to-be 45 §4 with the two-look rule and the summary rule.
2026-10-06 18:45:19 +02:00
mesh-admin 3f32462434 Merge pull request 'Issue 276: the audit logger and the usage store retry, and lose no event' (#145) from issues/276-the-audit-logger-and-usage-store-retry into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 16:38:07 +00:00
jochen 72fcdc366f Issue 276: the audit logger and the usage store retry, and lose no event
The operator decided the two consumers that took a failed write must retry and never lose an event: record how (a thrown write, a spool that takes the last delivery and replays, idempotent writes, a bound that borrows max-deliveries), and why the module counts its own deliveries.
2026-10-06 18:37:28 +02:00
mesh-admin 84707a783a Merge pull request 'ADR 0235: the bus is backed up by its own snapshot of each stream' (#144) from feat/bus-snapshot into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 16:24:54 +00:00
jochen e65daf6f4d ADR 0235: back the bus up by the server's own snapshot of each stream
Resolves ADR 0233's flagged item (the bus's streams copied as live files) as
a progressive insight pointing here. The bus module's own account is granted
the snapshot API and nothing else; restore builds a new store beside the live
one for a person to swap in. To-be 43 gains the bus's section and its restore
steps; design 25 and to-be 45 point to it.
2026-10-06 18:23:40 +02:00
mesh-admin 40e7911da2 Merge pull request 'Issue 276: a handler that did its work was offered it five times' (#143) from issues/276-a-handler-that-did-its-work-was-offered-it-five-times into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 16:17:06 +00:00
jochen fef40505ae Issue 276: a handler that did its work was offered it five times
A refresh answered with an empty body was read as JSON, so the media server module's handler threw after scanning; each finished download was offered five times and raised a false max-deliveries. Records the one rule for taking an event, and where the fixes are.
2026-10-06 18:15:58 +02:00
mesh-admin 3e32db519f Merge pull request 'Issue 275: a machine waiting for its push was said to be urgent' (#142) from issues/275-a-machine-waiting-for-its-push-was-said-urgent into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 16:07:18 +00:00
jochen 743de7572c Issue 275: a machine waiting for its push was said to be urgent 2026-10-06 18:06:43 +02:00
mesh-admin 754e0fb8e0 Merge pull request 'ADR 0233: a module declares the data it holds, and the mesh protects and watches it' (#141) from feat/a-module-declares-the-data-it-holds into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 15:11:21 +00:00
jochen 082693fb25 ADR 0233: how data is measured, and what the first night costs
Nothing large is walked; the first night's size on the home server is measured and stated, the
previews are left out, the platform database is dumped, and the bus's live copy is flagged.
2026-10-06 17:06:18 +02:00
jochen 7188d443bc ADR 0233: a module declares the data it holds, and the mesh protects and watches it
The operator's direction after issue 273: what data a module keeps, how precious it is and how it
is protected is said once, in the manifest, and backups, sticky bindings, retirement on unassign
and the self-check's watch are derived from it. Amends to-be 18, 32, 43 and 45.
2026-10-06 17:06:18 +02:00
mesh-admin 3f62e2bcc1 Merge pull request 'ADR 0234, to-be 46: the mesh holds a conversation with its operator' (#140) from decision/0234-the-mesh-holds-a-conversation-with-its-operator into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 14:59:31 +00:00
jochen 54fe510b54 ADR 0234, to-be 46: decide factor recovery, addressing, references and who is the operator
The operator found four gaps that would bite on first use: a lost phone
locked the mesh's only person out, an operator message had no addressee,
asks could not name the specifics they need, and the operator was a
holder's flag. Each is now a rule with its check and its build phase.
2026-10-06 16:58:59 +02:00
jochen fb30b75f20 ADR 0234, to-be 46: let the operator answer, and let only the controller act on an answer
Research 028 graduates: the mesh needs to ask as well as tell, over
channels that are seats rather than code inside one notifier, and an
action a person must approve cannot rest on a desk click an agent can
forge. TOTP verified by the controller is the proof; a key stays optional.
Amends to-be 45 section 5 as ADR 0227 left it to.
2026-10-06 16:39:11 +02:00
mesh-admin e77ce351cd Merge pull request 'Research 028: a conversation with the operator over channels that are seats; Telegram as first holder' (#139) from research/028-telegram-channels-and-triggers into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 14:33:22 +00:00
jochen b140ac9af7 Research 028: frame it as a conversation with the operator; add asks, work context and the desk
The operator widened the direction: asks need not be about permission, the work context picks
the channel, and desktop buttons should serve at the desk. Make the conversation the core model
and keep authorisation as a layer on top, with proofs a desk click alone cannot give.
2026-10-06 16:24:23 +02:00
jochen 62db332b3e Research 028: channels and intake as seats, decisions from a channel, Telegram as first holder
The operator asked to approve and reject through Telegram, for a generic shape in which a
channel holds a seat, for input triggers, and for capabilities that decide which actions travel
where. Widen 028 rather than open a new effort, since its Q1, Q7 and Q8 own the question.
2026-10-06 16:17:04 +02:00
mesh-admin 0c55ebf125 Merge pull request 'Issue 274: a provider is granted only the consumers bound to it' (#138) from issues/274-a-provider-was-granted-consumers-bound-elsewhere into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 14:09:29 +00:00
jochen c818e9c766 Issue 274: a provider is granted only the consumers bound to it 2026-10-06 16:08:30 +02:00
mesh-admin f197fcf146 Merge pull request 'Issue 273 and ADR 0232: a binding to a consumer's data moves only by a person' (#137) from issues/273-a-rule-for-the-resolver-moved-a-machines-databases into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 13:22:42 +00:00
jochen 732a17e112 Issue 273 and ADR 0232: a binding to a consumer's data moves only by a person
Issue 258's seat rule, written for the resolver, re-bound a machine's database consumers to the store
holding the seat elsewhere, where each was made an empty database, and nothing said so. Record the
incident, limit 258's rule to provisions that keep nothing, and decide that a binding to data is
kept and moved only by a pin.
2026-10-06 15:21:42 +02:00
mesh-admin cf9eec76c9 Merge pull request 'ADR 0226: the private network is assigned by its own name, and the proxy names its public issuer' (#128) from decision/0226-the-private-network-by-its-own-name into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 13:04:28 +00:00
jochen 4f981cc2d7 ADR 0226: say a retired definition is not ADR 0230's retired consumer
The two retirements are easy to confuse; the second deletes nothing a person must approve.
2026-10-06 15:02:18 +02:00
jochen 905ac0f0e4 ADR 0226: assign the private network by its own name, let the proxy name its public issuer
Two modules existed only to say one thing each: networking required mesh-wireguard and nothing else,
and public-acme pointed the one proxy at Let's Encrypt. dhcpcd and cloudflare-dns are assigned
nowhere. Retire all four, keeping every machine's network and every certificate as they are.
2026-10-06 14:59:26 +02:00
mesh-admin 89c7a4263d Merge pull request 'ADR 0230: a consumer the mesh stops asking for is retired, and deleted only by a person' (#135) from feat/retired-consumers into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 12:46:32 +00:00
jochen 4801aa87c1 Say that a retirement decision never asks for a healer
Approving and deleting are a person's act by design (ADR 0230); S15 of to-be
45 now says it leaves them out.
2026-10-06 14:46:09 +02:00
jochen da136ab40e ADR 0230: ten minutes as well as five passes, and mark-only where a provider cannot disable
Five passes are 25 s, shorter than a real hiccup; and a TypeScript provider's
remove can destroy, so retiring never calls it. Every TypeScript provider is
mark-only until it gains a retire that disables.
2026-10-06 14:40:40 +02:00
jochen 3904ef88bf ADR 0230: a consumer the mesh stops asking for is retired, and deleted only by a person
The operator's decision replaces ADR 0229's hourly release: the mesh no longer
finishes alone what its bound exists to question. To-be 45 and the module
protocol amended; 0229 and 0224 point to where their mechanism moved.
2026-10-06 14:40:40 +02:00
mesh-admin 4cdee43f69 Merge pull request 'ADR 0231: a healer acts on what observation raised, and only observation says it worked' (#136) from feat/a-core-that-cannot-fail-silently-phase-3 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 12:29:16 +00:00
jochen 6653f63779 ADR 0231: a healer acts on what observation raised, and only observation says it worked
Phase 3 of to-be 45 was built: the details its section 7 left open are
decided in the record and the design says them as built — where budgets are
counted, the settle, what a healer may send, H4 on D6's consumer-behind, the
report verb's shape, the mesh-wide brake and S15.
2026-10-06 14:28:08 +02:00
mesh-admin 343fc200f1 Merge pull request 'ADR 0229: the core's order is a lease the store remembers, and an epoch a machine is sent once it reads one' (#134) from decision/0229-the-cores-order-and-the-brake into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 10:43:13 +00:00
jochen c6e7f2c911 ADR 0229: the core's order is a lease the store remembers, and an epoch a machine is sent once it reads one
Phase 2 of to-be 45 met questions its paragraphs do not answer: a strict
node-engine refuses an unknown key, a lease bucket can be raised again from
nothing, a command at a shell sends declarations too, and the bus's grant
is composed by the controller that needs it. The answers, the wire contract
and the withdrawal brake go into to-be 45 with issue 270's Phase 1
decisions; issue 272 records that the SDK's provider loop says nothing on
the bus.
2026-10-06 12:26:33 +02:00
mesh-admin c99eb82e0e Merge pull request 'ADR 0228: a value given by hand lives only until its module's first good start' (#133) from feat/a-given-secret-lives-until-the-first-good-start into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 10:16:29 +00:00
jochen 9c7ec6ba75 ADR 0228: a value given by hand lives only until its module's first good start
The controller read 0113 as refusing to replace any value given to it, so
two leaked secrets only their own modules read could not be rotated without
a person handling a new value. Amends to-be 13 and points 0113 at it.
2026-10-06 12:15:29 +02:00
mesh-admin 2ccfd1d73d Merge pull request 'Issue 271: a new consumer replayed the morning to the operator' (#132) from issues/271-a-new-consumer-replayed-the-morning-to-the-operator into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 10:06:28 +00:00
jochen 04664e6acc Issue 271: a new consumer replayed the morning to the operator
The operator-channel's holder said three hours of cleared conditions as new
the moment its consumer was made; recorded so the class has a home, with
its fix in mesh-catalog #88 and the to-be 45 §5 amendment still owed.
2026-10-06 12:04:23 +02:00
mesh-admin 95c024b7c6 Merge pull request 'Issue 208: the bus's objects asserted on every send' (#131) from issues/208-bus-objects-asserted-on-every-send into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 09:47:02 +00:00
jochen e6e9d4dcc3 Issue 208: the bus's objects asserted on every send; fix pointer, the module half, Phase 3 note 2026-10-06 11:46:27 +02:00
mesh-admin 358cce00a7 Merge pull request 'Issue 270: Phase 1 watches signals that come later; to-be 45 in progress' (#130) from issues/270-phase-1-watchdogs-whose-signals-come-later into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 08:37:47 +00:00
jochen fc9b252949 Issue 270: Phase 1 watches signals that come later; to-be 45 in progress
Building Phase 1 of to-be 45 found four of its rows depend on later phases
and S9 hears only the controller's own connection; the decisions the build
made that the design does not state are recorded for an amendment.
2026-10-06 10:18:33 +02:00
mesh-admin aa52a0b6bb Merge pull request 'To-be 45 in progress; issue 269: the controller cannot write a cancelled set' (#129) from feat/a-core-that-cannot-fail-silently-phase-0 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 07:19:15 +00:00
jochen a6c0f002a5 To-be 45 in progress; issue 269: the controller cannot write a cancelled set
Phase 0 is being built in mesh-controller and mesh-host. Granting the
controller its own buckets showed that its grant never covered the work
queues' cancelled sets, so every cancel times out.
2026-10-06 03:04:36 +02:00
mesh-admin 213dcbafe9 Merge pull request 'Research 031: a core that cannot fail silently' (#125) from research/031-a-core-that-cannot-fail-silently into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 00:32:15 +00:00
jochen 0f163a3e7c Research 031 graduates: ADR 0227 and to-be 45
The operator approved the conclusion: the core must say when it is wrong
instead of waiting to be looked at. Record the nine rules with their
checks, and specify the parts and the six phases a build can take.
2026-10-06 02:31:21 +02:00
mesh-admin 09fa192cac Merge pull request 'ADR 0225: a consumer's identity is bounded by the provision it requires' (#127) from decision/0225-a-consumers-identity-is-bounded-by-the-provision-it-requires into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 00:28:47 +00:00
mesh-admin 3e55277aa9 Merge pull request 'Issue 268: letta printed its passwords into its log' (#126) from issues/268-letta-printed-its-passwords into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 00:25:32 +00:00
jochen 8fade781a7 ADR 0225: a consumer's identity is bounded by the provision it requires
Issue 263: one global 20-character bound held keyless provisions to an
object store's key, was found only when a provider composed, and then
refused the provider's whole machine. Take ADR 0049's option C, check
overflows before merge, and leave an overflowing consumer out of its
provider's grants instead of refusing the provider.
2026-10-06 02:18:03 +02:00
jochen f6f5563de2 Issue 268: letta printed its passwords into its log
Record the leak, its cause, the fix and the rotation steps the operator
approves once the fix runs, so the exposed secrets are replaced in order.
2026-10-06 02:15:00 +02:00
jochen 97ff655e34 Research 031: open the effort on a core that cannot fail silently
The operator's mandate: races, ignored commands and silence keep recurring.
Classifies 48 recent issues by class, proposes nine checkable principles and
a phased roadmap, for review before anything graduates.
2026-10-06 02:09:55 +02:00
mesh-admin 202f2aa144 Merge pull request 'Issue 267: a reconcile's report overtook the apply that followed it' (#124) from issues/267-a-reconcile-report-overtook-the-apply into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 23:47:11 +00:00
jochen d77399055c Issue 267: a reconcile's report overtook the apply that followed it
Record why a release plan waited on a report it had already been given,
and point issue 264 at it, since 264's fix was suspected and is not the
cause.
2026-10-06 01:46:33 +02:00
mesh-admin 3ba75a312b Merge pull request 'Issue 266: a merge on the bus was never handed to the controller' (#123) from issues/266-a-merge-the-bus-never-handed-the-controller into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 23:33:19 +00:00
mesh-admin 64c2d3a0a1 Merge pull request 'Issue 265: a push outlived its caller, and the bus refused its answer' (#122) from issues/265-a-verb-outlasting-its-caller into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 23:33:12 +00:00
jochen 14dabf60ee Issue 266: a merge on the bus was never handed to the controller
The bus server's multi-filter consumers skip messages on 2.10; record the
evidence, the reproduction, and the two fixes (server upgrade, catch-up).
2026-10-06 01:30:08 +02:00
jochen 1d8745b54d Issue 265: a push outlived its caller, and the bus refused its answer 2026-10-06 01:14:56 +02:00
mesh-admin f9e0517e0f Merge pull request 'Issue 244: located — the console drops a mesh seat's node, and a push ran on every machine' (#121) from issues/244-located into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:55:03 +00:00
mesh-admin 88f7f79fbb Merge pull request 'Issue 179 recurred; ADR 0224: a provider that keeps failing a consumer is a problem the controller reports' (#119) from issues/179-recurred-and-safety-nets into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:43:01 +00:00
jochen 3ee27b870d Issue 244: located — the console drops a mesh seat's node, and a push ran on every machine
The same empty schemas recurred for push, plan, assign, pin and settings;
for push the dropped machine became a push of the whole mesh. Record the
evidence, the cause in the console's address form, and the open fixes.
2026-10-06 00:38:37 +02:00
mesh-admin 9bcd714ee6 Merge pull request 'Issue 264: a self-updating engine lost the report of the apply that delivered it' (#120) from issues/264-self-update-lost-report into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:31:47 +00:00
jochen 3a8c23ef1a Issue 264: a self-updating engine lost the report of the apply that delivered it
Recorded so the release plan's stall on the anchor has a cause and a fix on
record; the fix is in mesh-host and not yet merged.
2026-10-06 00:30:45 +02:00
mesh-admin fd4df96c21 Merge pull request 'Design: resolv.conf is the uplink's holder's; a machine's names are node-hostname's (ADR 0223 parts 2–3)' (#117) from design/0223-uplink-resolv-and-hostname into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:20:32 +00:00
jochen 1e02593adf Issue 179 recurred; ADR 0224: a provider that keeps failing a consumer is a problem the controller reports
The identity provider's admin lost the mesh's password again when its database
moved, and 31,000 silent failures followed. Record the recurrence, the rule
that makes a failing provider visible in status, and the module's self-repair.
2026-10-06 00:14:38 +02:00
mesh-admin aa5a735994 Merge pull request 'Issue 263: every consumer pays for the tightest backend's name limit' (#118) from issues/263-the-identity-limit into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:09:58 +00:00
jochen 6db919f789 Issue 263: every consumer pays for the tightest backend's name limit 2026-10-06 00:09:53 +02:00
jochen 9b7fac3084 Design: resolv.conf is the uplink's holder's, a machine's names are node-hostname's (ADR 0223 parts 2 and 3)
The build of both parts is in review; the designs now say how they work and
are checked, and ADR 0223 records that the managers' own DNS mechanisms could
not write the mesh's file.
2026-10-05 23:44:41 +02:00
mesh-admin 947965e750 Merge pull request 'Issues 190, 259, 262: resolved' (#116) from issues/190-259-262-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:56:31 +00:00
jochen 00a0d75ced Issues 190, 259, 262: resolved and verified 2026-10-05 22:56:26 +02:00
mesh-admin 178994ee0d Merge pull request 'ADR 0223: the mesh has two resolvers, and a machine lists only them' (#115) from decision/0223-the-mesh-has-two-resolvers into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:48:09 +00:00
jochen 4f1300fd48 ADR 0223: the mesh has two resolvers, and a machine lists only them
musl asks every listed nameserver at once and takes the first reply, so ADR
0196's public fallback answered NXDOMAIN for mesh names in every Alpine build
on the home server. Decide two mesh resolvers and no public line now; record
resolv.conf moving to the uplink's holder and /etc/hosts with /etc/hostname
moving to one hostname seat as the next steps. Amend to-be 08 and 26.
2026-10-05 22:43:18 +02:00
mesh-admin 6e59281806 Merge pull request 'ADR 0222: a module is told where a mesh seat's holder is reached; the controller writes no file a seat's holder owns (issue 190)' (#113) from issues/190-controller-writes-no-owned-file into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:42:57 +00:00
jochen dae33af8b0 Merge main into issues/190-controller-writes-no-owned-file; index regenerated 2026-10-05 22:42:53 +02:00
mesh-admin 48e4ac9aa7 Merge pull request 'Glossary: node-engine replaces "host agent" and mesh-host' (#114) from glossary/node-engine into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:31:18 +00:00
jochen eb7356360f Glossary: the node-engine, the program every node runs, replaces "host agent" and mesh-host 2026-10-05 22:31:13 +02:00
mesh-admin 6719ab029c Merge pull request 'ADR 0221: a push sends no build a policy or a plan holds back, except to the machine it names' (#112) from decision/0221-a-push-sends-no-held-build into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:26:49 +00:00
jochen 93bed2147f ADR 0222: a module is told where a mesh seat's holder is reached; the controller writes no file a seat's holder owns
Issue 190's remaining steps: the runtime's module states the registry trust through
${seat:mesh-artifact-store:reach}, the private network stops writing it, generated resources
meet the collision check, and the rollout is an order rather than one push. ADR 0082 and 0102
get notes saying where their mechanism now lives.
2026-10-05 22:24:15 +02:00
jochen 95ce92c62f ADR 0221: a push sends no build a policy or a plan holds back, except to the machine it names
A named push's cascade sent every machine a build held back by `record` or by
a plan waiting on its first machine, so a change meant to be walked through
the mesh one machine at a time reached all of them at once (issue 259).
Records the decision, narrows ADR 0083's flush with a dated pointer, amends
to-be 30, and locates issue 259 in the controller.
2026-10-05 22:23:29 +02:00
mesh-admin 072489e653 Merge pull request 'Issues 260, 262: a service waits for what it reads; a mesh name has no IPv6 address rather than no name' (#111) from issues/260-262 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:08:22 +00:00
jochen eb4b4256a1 Issues 260, 262: a service waits for what it reads; a mesh name has no IPv6 address rather than no name 2026-10-05 22:08:12 +02:00
mesh-admin 553d0c8893 Merge pull request 'ADR 0220: what a machine asks needs its uplink held, and the retired resolver pieces go' (#110) from decision/0220-resolver-config-needs-the-uplink into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:02:59 +00:00
jochen 08643a2128 ADR 0220: resolver config needs the uplink; retired resolver pieces go
The rule that keeps resolv.conf the mesh's was checked by nothing, and a
retired seat and an unused module still read as live options. Amends to-be
26 and 08 to match.
2026-10-05 21:59:28 +02:00
mesh-admin 4fac135a46 Merge pull request 'Issues 257, 261: a reconcile applied an older declaration over a newer one' (#109) from issues/257-261-a-reconcile-applied-an-older-declaration into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 19:44:14 +00:00
jochen f391c5c36c Issues 257, 261: a reconcile applied an older declaration over a newer one 2026-10-05 21:44:09 +02:00
mesh-admin e6fbb86373 Merge pull request 'Issue 258: resolved' (#108) from issues/258-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 19:18:57 +00:00
jochen c8af8d5eb1 Issue 258: resolved 2026-10-05 21:18:49 +02:00
mesh-admin 0e193ebc41 Merge pull request 'Issues 258–260: what the move to one resolver met' (#107) from issues/258-260-the-resolvers-rollout into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 19:15:18 +00:00
jochen a0de734ed6 Issues 258–260: what the move to one resolver met 2026-10-05 21:15:09 +02:00
mesh-admin 55e776a1cd Merge pull request 'Issue 257: a plan waited on a declaration its first machine never reported' (#106) from issues/257-a-plan-waited-on-a-declaration-its-first-machine-never-reported into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 18:44:23 +00:00
jochen c233a1bbac Issue 257: a plan waited on a declaration its first machine never reported 2026-10-05 20:42:41 +02:00
mesh-admin 035d4a27e3 Merge pull request 'ADR 0219: the build queue is controlled through the controller and the build seat' (#105) from decision/0219-the-build-queue-is-controlled into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 18:22:01 +00:00
jochen 77429488b0 ADR 0219: plans say what their builds wait on, and a failed plan can go on 2026-10-05 18:56:18 +02:00
jochen 3944e4f914 ADR 0219: the build queue is controlled through the controller and the build seat
The operator asked for tools to see, cancel, clear, stop, pause, continue,
restart and replay builds; none existed. Queue actions are the controller's,
process actions the build seat's per machine, and every action that drops
work leaves a failed outcome so no plan waits on it. To-be 18 amended.
2026-10-05 18:54:44 +02:00
mesh-admin ab1fbe3202 Merge pull request 'Issues 224, 248-252, 254, 255 resolved; 256 opened and resolved; 253 where it stands' (#104) from records/resolved-2026-10-05 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 16:36:27 +00:00
jochen 0fdcb15200 Issues 224, 248-252, 254, 255 resolved, 256 opened and resolved, 253 where it stands
Each resolved with the pull request that fixed it and what was seen live;
253 waits for real collection, the operator's word to give.
2026-10-05 18:36:19 +02:00
mesh-admin 91aa481269 Merge pull request 'Issue 255: the journal verb read nothing for a system service' (#103) from issue/255-the-journal-verb-read-nothing into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 16:10:02 +00:00
jochen 0627b7f1f3 Issue 255: the journal verb read nothing for a system service 2026-10-05 18:09:44 +02:00
mesh-admin 049b50b66c Merge pull request 'Issue 248: delivery restored; a merge still heard twice has another cause' (#101) from issue/248-a-merge-heard-twice into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 15:55:48 +00:00
mesh-admin 44808ac563 Merge pull request 'ADR 0218 and issues 250-254: delivery in order, and a store that keeps what it says' (#102) from records/delivery-and-store into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 15:55:45 +00:00
jochen f000288147 ADR 0218 and issues 250-254: delivery in order, and a store that keeps what it says
ADR 0218: grants before code, one machine first, a newer merge takes over an
older plan (to-be 30 amended). Issues 250 (a merge announced twice), 251 (the
record's checkout owned by another account), 252 (a merge's changed modules
read wrong), 253 (the collector would delete every kept archive; to-be 18
amended, ADR 0189 corrected as a progressive insight), 254 (plans run over
each other); 249 located.
2026-10-05 17:51:03 +02:00
jochen 80aff0f457 Issue 248: delivery restored; a merge still heard twice has another cause 2026-10-05 17:30:18 +02:00
mesh-admin 400ed8d396 Merge pull request 'Issues 248, 249: the controller's event consumer replayed a week; a new state is refused until a push' (#100) from issues/248-249-delivery into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 15:27:24 +00:00
jochen df2072aed5 Issues 248 and 249: the controller's event consumer replayed a week; a new state is refused until a push
248, located: a consumer made with the server's default replays the whole
stream, and the controller's held every new merge and build behind a week of
old ones. 249, open: a module's new state reaches its bundle before the
grants that let it use it.
2026-10-05 17:16:35 +02:00
mesh-admin ae58570209 Merge pull request 'Issue 247: a module cannot put the operator's account in a group' (#99) from issues/247-a-module-cannot-put-the-operator-in-a-group into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 13:45:52 +00:00
mesh-admin 855c3f9b33 Merge pull request 'Issue 246: the console says a module runs nowhere when a runtime answers late' (#98) from issues/246-the-console-loses-the-largest-runtime into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 13:45:49 +00:00
jochen f93b02900c Issue 247: a module cannot put the operator's account in a group 2026-10-05 15:45:24 +02:00
jochen 9306192f93 Issue 246: the console says a module runs nowhere when a runtime answers late 2026-10-05 15:40:14 +02:00
mesh-admin 8bd56a2820 Merge pull request 'Issue 243: resolved' (#87) from issue/243-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 13:17:44 +00:00
mesh-admin 7497149eed Merge pull request 'Issue 245: behind is what a plan decided and has not yet done' (#96) from issues/245-the-plan-is-the-truth into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 13:17:04 +00:00
jochen 1b9196b807 Issue 245: behind is what a plan decided and has not yet done (the operator's direction) 2026-10-05 15:16:51 +02:00
mesh-admin b3e7191ddc Merge pull request 'Issue 245: status calls a module behind when only its repository moved' (#95) from issues/245-behind-means-its-files-changed into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 13:15:57 +00:00
jochen 87f80573b8 Issue 245: status calls a module behind when only its repository moved 2026-10-05 15:15:44 +02:00
mesh-admin 8aeef9969d Merge pull request 'ADR 0189 collects again; issue 244: a verb with an empty schema cannot be called' (#93) from decision/0189-collects-again into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 12:38:40 +00:00
jschoubben 8ade75c550 ADR 0189 collects again; issue 244: a verb with an empty schema cannot be called
The nightly collector is back on the store (mesh-catalog#57). It was out for
a day because while-stopped named the module-local id and the host refuses a
declaration naming a container it does not have, whole; the namespacing was
fixed the same night and the composition was read before anything was sent
this time — plan's distribution.collect shows while-stopped as
distribution.store. novox applied it with no refusal and the registry was
untouched.

The store is 40G at 14:37, the filesystem 56% full; the collector first fires
at 03:30. Deleting a manifest frees no bytes until then, so that is the
number to read against.

244: mesh-controller.plan and .node publish an empty schema and then refuse
with 'needs node', including when node is passed — the console drops what
the schema does not declare. A tool that cannot be called is worse than one
that is absent, because it is listed. Worked around through the login shell,
which is the path the console exists to replace.
2026-10-05 14:38:17 +02:00
jschoubben 30bd65ad7b Merge pull request 'to-be 43: in progress' (#92) from feat/node-backup into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 10:24:16 +00:00
mesh-admin b59d479992 Merge pull request 'ADR 0216: the agent's configuration is registered through its module, at three scopes, and served as one plugin' (#89) from decision/the-agent-configured-through-its-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:50:44 +00:00
jschoubben 4f599f361b to-be 43: in progress — the seat, the restic holder and the stores' contributions 2026-10-05 11:47:59 +02:00
jochen f291d113c8 ADR 0216: the agent's configuration is registered through its module, at three scopes, and served as one plugin
Graduates research 029 and amends design 36 (section 8): skills, subagents,
commands, hooks and output styles in one nox-mesh plugin; servers, settings
and instructions in the managed files; mesh, node and home scopes.
2026-10-05 11:45:58 +02:00
jschoubben daf2f6d2b1 Merge pull request 'to-be 43: backups against mistakes (graduates research 030)' (#90) from design/43-backups-against-mistakes into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:35:22 +00:00
mesh-admin 4924b26b3c Merge pull request 'ADR 0215: the machine's message bus is a node seat, and it is never restarted live' (#91) from decision/0215-the-message-bus-is-a-node-seat into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:33:36 +00:00
jochen d088f8ec2f ADR 0215: the machine's message bus is a node seat, and it is never restarted live 2026-10-05 11:33:20 +02:00
jschoubben 431375d16c to-be 43: backups against mistakes, declared by modules, kept on the machine
Graduates research 030 into a design for ADR 0214, which merged without one and left the cycle
check failing on main.
2026-10-05 11:31:19 +02:00
jschoubben e51d6f1d86 Merge pull request 'Research 030 + proposed ADR 0214: backups against our own mistakes' (#88) from research/030-backups-against-our-own-mistakes into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:30:35 +00:00
jschoubben a906cd8e67 ADR 0214: accepted by the operator 2026-10-05 11:30:33 +02:00
jschoubben 3d0ce3e6dd Research 030 and proposed ADR 0214: backups guard against mistakes and stay on the machine
The operator scoped backups to mistakes, not disasters (issue 242). Issue 238: fix the failed logins
at their source instead of exempting the operator's address.
2026-10-05 10:07:36 +02:00
jochen 9197b99153 Issue 243: resolved by nodes applying any differing binding and the manager never repeating a generation 2026-10-05 10:03:49 +02:00
mesh-admin bc51d8e7aa Merge pull request 'Issue 195: resolved' (#86) from issue/195-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 08:01:20 +00:00
mesh-admin 85c6db0600 Merge pull request 'Research 029: the agent configured through its module, as a plugin the mesh serves' (#85) from research/029-the-agent-configured-through-its-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 08:01:16 +00:00
mesh-admin 9d18968323 Merge pull request 'Issue 243: a rebuilt licence store silences every node' (#84) from issue/243-a-rebuilt-licence-store-silences-every-node into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 08:01:13 +00:00
jochen 127675e2da Issue 243: a rebuilt licence store silences every node
The manager's generation counter restarted with its store after issue 241,
and nodes that wait for a higher number discarded every binding unseen.
2026-10-05 09:59:17 +02:00
jochen 0e87aad949 Research 029: a running session takes the plugin on reload 2026-10-05 09:53:13 +02:00
jschoubben 2a501af0eb Merge pull request 'Issues 239–242: a name taken over, a dry run recorded, every database dropped, no backups' (#83) from issues/239-242 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 00:25:50 +00:00
jschoubben bd7fc40099 Issue 239: name no installation 2026-10-05 02:24:27 +02:00
jschoubben 93d4d29b72 Issues 241, 242: one unreadable grants file dropped every database, and the mesh has no backups
241: the SDK harness read a failed read as "no consumer" and withdrew all seven databases on the
control node; withdrawal destroyed data in seven providers. Fixed in mesh-sdk 0.1.10, mesh-catalog#44
and mesh-host#22; the recovery and what it lost are recorded. 242: nothing backs anything up.
2026-10-05 02:24:11 +02:00
jochen 6ff2440e5d Research 029: the plugin route confirmed on one workstation
Six of seven checks confirmed and autoMode from managed settings very likely;
the agent cannot undo its own managed settings, which a design must allow for.
2026-10-04 23:46:32 +02:00
jochen 65c3315a14 Research 029: the agent's configuration is managed at three scopes
The operator's direction: mesh, node and home. A plugin carries the first
two; the home scope places items the module owns path by path; instructions
follow the same layering.
2026-10-04 17:52:48 +02:00
jochen 854341d4f0 Research 029: the agent configured through its module, as a plugin the mesh serves
Skills, subagents and hooks have no machine-wide place but a plugin, and
hand-copied homes have drifted and gone stale on every machine. Records the
evidence, what the vendor allows, and the options a decision must settle.
2026-10-04 17:46:24 +02:00
jochen d444458bf6 Issue 195: resolved by the controller composing users only for modules that can read an account 2026-10-04 17:37:20 +02:00
mesh-admin dd8b15a0df Merge pull request 'Issue 195: located in the controller's user composition' (#370) from issue/195-diagnosis into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 15:34:11 +00:00
mesh-admin 02b4ca9bec Merge pull request 'ADR 0213: the operator sets the agent's managed settings through the agent module' (#369) from feat/claude-code-agent-settings into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 15:34:09 +00:00
jochen 7cd5b37afe Issue 195: located in the controller's user composition
Records why a module that cannot read an account is no bus user, what else
read those users (durable consumers), and answers the report's questions.
2026-10-04 17:33:27 +02:00
jschoubben aac0da9c0c Merge pull request 'ADR 0199: a module that answers names declares its zone, and a node's hosts file is one module's' (#337) from decision/0199-zones-and-the-hosts-file into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 15:33:07 +00:00
jochen 57b818b669 ADR 0213: the operator sets the agent's managed settings through the agent module
Rules for what the agent may do were set by hand per machine, invisible to
the mesh, and a session cannot loosen its own permissions; design 36 now
takes them as a module setting under the mesh's own keys.
2026-10-04 17:32:18 +02:00
jschoubben 33c10aa85a Issues 239, 240, and 238's diagnosis: a module name taken over, a dry run recorded, the operator banned
239: two repositories defined photos; a rebuild to the catalogue's main replaced the app with a stub
and nothing refused it. 240: a dry-run build was recorded and its definition reached the machine.
238: the forge refused three ssh logins as the operator's account in 31 seconds; nothing in the mesh's
ssh configuration names the forge, and no jail ignores the mesh's own public addresses.
2026-10-04 17:31:29 +02:00
mesh-admin e5042e1e7a Merge pull request 'Issues 237, 238: a self-contradicting assign answer, and the operator's address banned' (#367) from issues/237-238-a-stale-answer-and-a-banned-operator into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 15:06:51 +00:00
jochen 2580fb6839 Issues 237, 238: a self-contradicting assign answer, and the operator's address banned 2026-10-04 17:06:38 +02:00
mesh-admin 4a8a378ef9 Merge pull request 'ADR 0212: a seat says what it receives, and the machine's hotkeys are a seat' (#366) from decision/0212-contributions-to-a-seat-and-hotkeys into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 14:42:31 +00:00
jochen 3f48e685cc ADR 0212: a seat says what it receives, and the machine's hotkeys are a seat 2026-10-04 16:42:21 +02:00
mesh-admin 1e0b9ee11f Merge pull request 'As-is: a module's state, and the agent with its licences; designs 36, 39, 40 implemented' (#365) from design/state-and-licences-as-is into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 14:33:50 +00:00
jochen 1faa63b2d5 As-is: a module's state and the agent with its licences; designs 36, 39 and 40 implemented
What runs since 2026-10-04 and what its first live use showed: refused
requests as timeouts, a late machine reading the whole set, the partial
secrets guard; the agent module and the licence manager on every machine.
2026-10-04 16:32:29 +02:00
mesh-admin 3a359bf99e Merge pull request 'ADR 0211: a machine's power is a node seat, its moments take contributions, and its states are events' (#364) from decision/0211-power-is-a-node-seat into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 13:58:25 +00:00
jochen ec6d106af8 ADR 0211: a machine's power is a node seat, its moments take contributions, and its states are events 2026-10-04 15:58:16 +02:00
jochen e2b4f3a5c4 Regenerate the decision index 2026-10-04 15:58:09 +02:00
mesh-admin dfb2817fe7 Merge pull request 'ADR 0210: a tool's configuration is its seat holder's, and every other module extends it through the seat' (#361) from decision/0210-a-contribution-depends-on-the-seat-that-receives-it into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 13:42:56 +00:00
mesh-admin 12742e3a3f Merge pull request 'Designs 36 and 39: the manager's verb is public-key' (#363) from fix/the-verb-is-public-key into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 13:41:32 +00:00
jochen 5b00bdc7af Designs 36 and 39: the manager's verb is public-key (a seat's verb takes no underscore) 2026-10-04 15:41:23 +02:00
mesh-admin d5a96e5c63 Merge pull request 'Research 028: the mesh's output channel' (#362) from research/028-the-meshs-output-channel into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 13:36:17 +00:00
jochen 73e6400802 Research 028: the mesh's output channel — how the mesh tells its operator what it noticed 2026-10-04 15:36:03 +02:00
jochen f144ad7be4 To-be 42: who writes what in phase 2 (ADR 0210) 2026-10-04 15:20:01 +02:00
jochen 8394a7bfb1 ADR 0210: a tool's configuration is its seat holder's, and every other module extends it through the seat 2026-10-04 15:19:44 +02:00
mesh-admin 6ac936f170 Merge pull request 'Issues 235, 236: an assignment and a check that let through what then fails' (#360) from issues/235-236-an-assignment-and-a-check-that-let-through-what-fails into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 13:19:41 +00:00
jochen 3af4179755 Issues 235, 236: an assignment and a check that let through what then fails 2026-10-04 15:17:46 +02:00
mesh-admin dd04729ec5 Merge pull request 'ADR 0209: a login on a node moves that node to its account; an API key is added from any node, sealed' (#359) from decision/0209-a-login-moves-its-node into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 13:12:01 +00:00
jochen 2e5f40c9fa ADR 0209: a login on a node moves that node to its account; an API key is added from any node, sealed
Traced live: a login to a second account was adopted and left its node
bound to the first, holding a spent refresh token. Designs 36 and 39
amended.
2026-10-04 15:09:56 +02:00
mesh-admin 2f41b4124d Merge pull request 'Issues 233, 234: a stale declaration removed four modules, and the host could not recover' (#358) from issues/233-234-a-stale-declaration-and-a-host-that-cannot-recover into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 13:09:47 +00:00
jochen 9cc00d57ea Issues 233, 234: a stale declaration removed four modules, and the host could not recover 2026-10-04 14:54:50 +02:00
mesh-admin 438162b5a5 Merge pull request 'Issues 225, 226 and 227 resolved with their live proofs; 232 opened and resolved' (#357) from issues/228-photos-authenticates-against-admin into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:37:53 +00:00
jschoubben ab1bd5598e Issues 225, 226, 227 resolved with their live proofs; 232 opened and resolved
225: a grant secret is composed with the account that reads it — the node's
account for a bundle, the declared secrets-owner for a container. Zero EACCES
since 12:30:10 where there had been 4330, both users created, mongodb logging
Authentication succeeded for each. The harness also stops calling a permanent
refusal a race, which is the half that cost three hours.

226: normalising moved to the records, where the provenance is known, and a
reference the sweep will not address is skipped rather than ending the sweep.
The first build after the roll-out collected 200 and said 1126 remain — the
backlog falls with every build instead of standing at 1681 for ever.

227: the three photo modules publish the endpoint they declare, and a
catalogue-wide test makes it a rule: a container publishes only a port its
module declares, or the mesh has nothing to assign and the number escapes.

232 came out from under 225: photos asked for a database its user does not
live in, invisible while no user existed at all.
2026-10-04 12:37:31 +02:00
jschoubben 3f3fb99219 Issue 232: a consumer authenticates against a database its user does not live in
Found fixing 225: with the secrets readable the provisioner created both
users at once, and photos still could not connect because it asks for admin
while its user lives in its own database. The password was never wrong — the
grant secret and the consumer's environment hash identically.

One fault wore the other's clothes: while no user existed anywhere, the
error was a complete account of 225. Worth keeping as a habit — fix the
first and look again.
2026-10-04 12:33:57 +02:00
mesh-admin 3afe619531 Merge pull request 'ADR 0208: the graphical session is one module per piece, on the mesh's seats' (#356) from decision/0208-the-graphical-session-is-modules-on-seats into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:29:20 +00:00
jochen 502cf4839b ADR 0208: the graphical session is one module per piece, on the mesh's seats
Eleven node seats; a display as a provision with the machine's reach; other
modules' lines through the tool's own drop-in directory or ADR 0204's slots,
now also for xinitrc and xresources; the display server's module writes the
session's start.
2026-10-04 12:29:13 +02:00
mesh-admin 0ba68c154e Merge pull request 'Issue 162 resolved: an archive can be undeclared (mesh-host#90)' (#355) from issues/162-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:25:44 +00:00
jochen 460793af1c Issue 162 resolved: an archive can be undeclared (mesh-host#90) 2026-10-04 12:23:06 +02:00
mesh-admin 25de331e9b Merge pull request 'ADR 0207: a module depends on the node seats that apply its resources' (#354) from decision/0207-a-module-depends-on-the-seats-that-apply-its-resources into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:21:46 +00:00
jochen ed5ddcdef6 ADR 0207 extends ADR 0177 (accepted); 0166 stays a reference 2026-10-04 12:21:40 +02:00
jochen e4f80cc3ce ADR 0207: a module depends on the node seats that apply its resources
A service needs node-service-manager held on its node, a package
node-package-manager, a container node-container-runtime: derived from the
resources, never stated; refused at assign, reported at composition until the
three holders are on every node. Glossary: depends on a seat; nothing claims a
package.
2026-10-04 12:21:24 +02:00
mesh-admin d2689c0f86 Merge pull request 'Issues 229 and 230: a rollout cannot be followed through the mesh's tools; a host hand-over loses its report and a plan waits for ever' (#352) from issues/229-a-rollout-cannot-be-followed-through-the-meshs-tools into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:09:34 +00:00
mesh-admin 719aa6bd62 Merge pull request 'Research 026 and 027, issue 231, to-be 42: the graphical session, the system layer, and the order they are built in' (#353) from research/026-027-the-graphical-session-and-the-system-layer into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:08:33 +00:00
jochen ca13f59c88 To-be 42: the machines' modules, in order — every machine's, then the workstations', then one model's 2026-10-04 12:08:26 +02:00
jochen f8a0402485 Merge remote-tracking branch 'origin/main' into research/026-027-the-graphical-session-and-the-system-layer 2026-10-04 12:08:17 +02:00
jochen 9016d88d54 Research 026/027: improve while adopting, the fonts chosen, and a catalogue of the tools each module serves 2026-10-04 12:06:08 +02:00
jochen 6e5dfd2ab8 Research 027/03: the laptop's power management; 026/04: fonts 2026-10-04 12:03:29 +02:00
mesh-admin 872f20d51f Merge pull request 'To-be 40: building the operator's agent and its licence manager as work packages' (#297) from feat/claude-code-work-packages into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 10:01:15 +00:00
jochen 550453c5db Research 026/04: the clipboard 2026-10-04 12:00:20 +02:00
jochen b7aebedc2d Research 026/04: the screensaver, monitor layouts and menus 2026-10-04 12:00:01 +02:00
jochen 27b2d30441 Research 027/03: ~/.ssh as one module's, scripts on every machine, the keyring, mail as events 2026-10-04 11:59:23 +02:00
jochen 82fa5f79ea ADR 0206: a node reports the grant it holds; the manager adopts a licence by refreshing it
The operator's flow: clients publish what their credentials file holds, the
manager takes in a licence it does not own and rotates it from then on. The
token itself cannot be published (design 32 §10, ADR 0201), so a node reports
fingerprints and identity as state and hands the grant over only when the
manager asks; adopting is refreshing, newest login first; bindings with a
generation replace the rotated/switched events. Designs 36 and 39 and to-be 40
amended; a pointer note on ADR 0183.
2026-10-04 11:58:46 +02:00
jochen f6668d76d6 There is no home-scoped module: ADR 0181 and 0182 say so as progressive insights; design 36 and to-be 40: the module declares the two directories it owns
ADR 0173 §2: a module is what it declares, and there are no kinds of module. The two records called
a resource under a home and a module placing one home-scoped; the wording is corrected in place,
marked and dated, the decisions unchanged. Design 36 and to-be 40 now say the module declares
/etc/claude-code and ~/.claude as directories, so the ownership check sees both, and declares no file
under either (mesh-catalog #244).
2026-10-04 11:56:32 +02:00
jochen f5d54db7aa Plan and designs after ADR 0193, 0195 and 0198: bundles are launched and the runtime is their bus; the manager's daemon is a long-running bundle; the console's five tools
The dated note on ADR 0183 now rests on ADR 0193 and 0198 rather than on a bundle having no way to
call: the manager starts every exchange by the operator's direction, through mesh/ask. To-be 40's
WP4 no longer waits on a record — ADR 0198 is it — and the live proofs count the console's five
tools (ADR 0195).
2026-10-04 11:56:32 +02:00
jochen bcf010886d Design 36 §4: the console is registered in the exclusive managed tool-server file, because the managed-settings key refuses a non-https URL 2026-10-04 11:56:32 +02:00
jochen 2eba399e1e To-be 40 revised for the tools refactor; the manager starts every exchange (ADR 0183 dated note, designs 36 and 39)
Design 38's WP1-WP4b ran: the node's tool runtime is live on all four machines as the operator
account, tools are bundles given only their declared words, and a bundle has no bus credential.
So the wait on design 38 WP3 is over, the agent module calls nothing and the manager starts every
exchange (key, hand-over, waiting login, reconcile), and the manager's daemon now waits on WP4c's
record instead. Accounts are stated on all four, sudo -n works for each, the agent is installed on
all four; the plan's WP0 shrinks and WP2 gets a configuration-only live proof before any licence.
2026-10-04 11:56:32 +02:00
jochen d227ed12d2 To-be 40: building the operator's agent and its licence manager as work packages
Designs 36 and 39 say what is built; this says in which order and what proves each step, in the
shape to-be 38 gave the operator's machine. Seven packages: the operator states the facts (accounts,
roles, licences); the console provides its endpoint; the licence manager and the agent module are
built and unit-tested in parallel; the manager goes live on the control node; the agent on one
workstation, with the switch and the predecessor's files removed as the proof of the whole; then the
rest of the nodes and the retirement of the two catalogue modules built on the old placement. The
live proofs wait for to-be 38's WP3, because both modules' tools run in the node's tool runtime
(ADR 0175) and a per-module tool container would rebuild what that record retires.
2026-10-04 11:56:32 +02:00
jochen 8712d666bf Research 026/03: what the predecessor taught; issue 231: a misspelled placeholder is written out as text 2026-10-04 11:49:19 +02:00
jochen 61e70b9395 Research 027: the operator's choices, the hosts file, mounts, and two DHCP clients on one interface 2026-10-04 11:21:35 +02:00
jochen de032e704c Research 026 (the graphical session) and 027 (the system layer)
Evidence from both workstations and all four machines, read-only, and the
questions each must answer: seats and gating for the display stack, who starts
the session with which environment, contributions beyond shells, sway as a
sibling session; the container runtime with docker-compose on workstations
only, software outside the official repositories, secrets in the account's
environment, and three security findings.
2026-10-04 11:17:34 +02:00
jochen 0c2eae07c5 Issue 229: the stale tool list is a connection opened before discovery; the fix is list-changed 2026-10-04 11:01:50 +02:00
jochen f23a71e0d7 Issue 230: a report is also lost when the apply restarts the bus 2026-10-04 11:00:27 +02:00
jochen 9c13c89fa3 Issue 229: new tools never reach an agent's connection, and the console's generic tools are not on it 2026-10-04 11:00:13 +02:00
jochen 2db0ea268d Issue 230: a host that hands over loses its report, and a plan waits for it for ever without saying so 2026-10-04 10:54:35 +02:00
jochen 8d83d94659 Issue 229: a rollout cannot be followed through the mesh's tools 2026-10-04 10:51:45 +02:00
mesh-admin a8ffc2b94b Merge pull request 'Research 025 graduated: ADRs 0203–0205, issue 228, to-be 41 (the shell and the account's environment)' (#351) from feat/the-shell-and-its-environment into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 08:49:36 +00:00
jochen 1dcbdae1c4 Issue 225 → 228: the number was taken on main while this branch was open 2026-10-04 10:30:46 +02:00
jochen c3ec48f85c To-be 41 WP1: directories made inside a home belong to its account 2026-10-04 10:30:23 +02:00
jochen 72eda923c7 ADR 0205: the vendored theme's measured size 2026-10-04 10:30:23 +02:00
jochen c4fedcdbe3 To-be 41 WP1: a shell that refuses logins need not be listed; giving back is never fatal 2026-10-04 10:30:23 +02:00
jochen 0bf70ee8b4 Graduate research 025: the environment and the shell's contributions
ADR 0203: the account's environment is one module's (seat node-environment);
every module contributes variables and PATH entries, rendered by the
controller as a POSIX file and as environment.d.
ADR 0204: shell code is contributed to the login shell in named slots, and
login-shell becomes the mesh's node-login-shell.
ADR 0205: software the distribution does not package ships as a pinned
archive of the module.
Issue 225: undeclaring a user stops a node applying; the shell is never
given back or checked.
To-be 41 carries the work packages; to-be 38 WP5 points to it.
2026-10-04 10:30:23 +02:00
jochen 947b85af5e Research 025: an environment module holds the account's environment
The operator's proposal: one module, holding a mesh seat node-environment, is
the only writer of the account's environment. It renders contributed variables
and PATH entries as a POSIX file shells source and as environment.d for the
graphical session. The shell's contribution shrinks to shell code; the shell
render-then-service-manager-again positions become options weighed against it.
2026-10-04 10:30:18 +02:00
jochen ac6c306df3 Research 025: how a module plugs into the operator's shell
Opened after the zsh module's rollout (to-be 38 WP5) was stopped: every machine
carries the same predecessor-written startup file, the module's block would
duplicate it and drop lines, nothing installs the prompt, and execute never
reads .zshrc. Weighs how modules contribute environment and shell code, where
the operator's own lines go, ordering, and what a contribution is addressed to.
2026-10-04 10:30:18 +02:00
mesh-admin 96df3ccc88 Merge pull request 'ADR 0201 → 0202, and three issues from the night group 8 shipped' (#350) from issues/the-night-of-group-8 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 02:34:32 +00:00
jschoubben bc64c5c187 ADR 0201 → 0202, and three issues from the night it shipped
The derived-value record is renumbered a second time: the key-value-buckets
record took 0201 while this waited to merge, as the bundles record took 0188
before it. Both times free when chosen, taken by the time it landed. cycle.py
caught it; three repositories cite this record, so the number matters.

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

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

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

That last one is issue 224, recorded rather than fixed: the host's rule for a
stopped container is to replace it, and while-stopped is the first thing that
makes a stopped container intentional. Both candidate fixes are decisions with
their own cost. Nothing is worse than it was; the store has never collected.
2026-10-04 03:27:32 +02:00
jochen 6b4da63261 Research 024: the composed grants, checked against a server once built 2026-10-04 02:50:02 +02:00
jschoubben 0231974226 Rebased onto main: ADR 0188 renumbered to 0201, and issue 202's evidence re-taken
The bundles refactor took 0188 on main while this waited in a pull request,
and the mesh's own code cites that one, so this record moves. Only the number
moved; the decision is the one taken on 2026-10-02, and the record says so.

Issue 202 re-checked against the refactored main: the fault stands, and the
test that surfaced it now fails one step earlier on issue 203's new credential
guard. Proven again past both — mint the credential, compose twice, and all
eight of dnsmasq's resources appear only with the setting set. ADR 0164 is
noted as the decision that answers half of it, and is not built.
2026-10-04 02:44:58 +02:00
jschoubben 92c029d10e ADR 0189: the store keeps what the records name, and a maintenance step holds its writers still
Issue 108: the artifact store has never collected anything. 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.

The mesh decides what may go — from its own build records, so it never names
a digest it did not put there — and the store reclaims the bytes in a nightly
window with its server held still. Deletion on the one door takes nothing a
push did not already have.

Designs 18 and 20 amended; issue 108 resolved.

Also issue 202, found running the controller's suite: a module whose required
setting nobody set is left out of the machine in silence, and dnsmasq became
that module this morning.
2026-10-04 02:40:25 +02:00
jschoubben 2a60da821d ADR 0188: a provider declares what it derives for each consumer, and the mesh tells both ends
Issue 124: a value the mesh's own rule produced reached neither end as a
statement. The object store's provisioner derived each consumer's bucket in
its own code; all three consumers transcribed the rule into their own
definitions, one of them wrong, and each of the three also named the machine
it happens to run on.

A served value may now name the consumer the mesh is serving. Design 27
amended; issue 124 resolved.
2026-10-04 02:40:06 +02:00
jochen e1b0bbde91 Research 024 and ADR 0201: a module keeps its current state in key-value buckets
Events miss a machine that joins after them and replay history where only the
latest matters. A module now declares state it owns and reads; the controller
creates the buckets, the runtime serves them on the bundle's channel. Designs 32
and 25 amended; grants measured against a running server.
2026-10-04 02:36:59 +02:00
mesh-admin 8ca09c70d6 Merge pull request 'Issues 213 and 223 resolved' (#347) from issues/213-223-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 00:19:22 +00:00
jochen db71b83711 Issues 213 and 223 resolved: the controller runs as a process, and genesis hands over to it 2026-10-04 02:19:09 +02:00
mesh-admin 9143d0b7c1 Merge pull request 'ADR 0200: genesis pivots to the controller as a container, and the first push hands it to a process' (#346) from decision/0200-genesis-pivots-to-a-container-and-hands-over into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 23:41:36 +00:00
jochen 24a51a8e53 ADR 0200: genesis pivots to the controller as a container, and the first push hands it to a process 2026-10-04 01:41:30 +02:00
mesh-admin f0d7f91d90 Merge pull request 'Design 38: WP4c complete' (#345) from design/38-wp4c-complete into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 23:35:31 +00:00
jochen 8578a06ca8 Design 38: WP4c complete, no module's own code runs in a container 2026-10-04 01:35:16 +02:00
mesh-admin 86083f9c9d Merge pull request 'Issues 215, 221, 222 resolved' (#344) from issues/215-221-222-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 23:29:38 +00:00
jochen 63d328147e Issues 215, 221 resolved with live proof; 222 diagnosed and resolved 2026-10-04 01:29:32 +02:00
mesh-admin fead0ea440 Merge pull request 'Issues 219-223 and design 38 WP4c built' (#343) from issues/219-223-and-wp4c into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 23:14:49 +00:00
jochen df503d1cff Issues 219, 220 resolved, 221 located, 222 and 223 opened; WP4c built and proven 2026-10-04 01:14:44 +02:00
mesh-admin c2fc829822 Merge pull request 'Issues 211, 212, 214, 216, 217 resolved; 215's fix recorded' (#342) from issues/211-217-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 22:25:07 +00:00
jochen 8d8e5c9a7e Issues 211, 212, 214, 216, 217 resolved with their proofs; 215's fix recorded 2026-10-04 00:24:53 +02:00
jschoubben 3ed55a3420 connectivity: name the code that builds the one resolver, zones and the hosts file 2026-10-04 00:23:43 +02:00
mesh-admin affba60b79 Merge pull request 'Issues 219-221: the builder's ordering gaps' (#341) from issues/219-221-the-builder into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 22:16:13 +00:00
jochen 9ddbc4c68e Issues 219-221: the builder's ordering gaps seen while rolling out issue 218 2026-10-04 00:16:08 +02:00
mesh-admin a972db91f0 Merge pull request 'Issue 218 resolved: the runtime follows its membership, and a refused subscription is not fatal' (#340) from issues/218-rollout into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 22:14:10 +00:00
jochen 029698fdc8 Issue 218 resolved: the runtime follows its membership, and a refused subscription is not fatal 2026-10-04 00:14:04 +02:00
mesh-admin 895c2afad1 Merge pull request 'Issue 218: a mesh seat answered by a non-holder (located, fixed in mesh-controller#248)' (#339) from issues/218-a-mesh-seat-answered-by-a-non-holder into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 21:32:24 +00:00
jochen 4b8c5e3b11 Issue 218 located: the controller issued a mesh seat to every claimant; fixed in mesh-controller#248 2026-10-03 23:31:13 +02:00
mesh-admin d362155401 Merge pull request 'Issue 218: a mesh seat is answered by a module on a machine that does not hold it' (#338) from issues/218-a-mesh-seat-answered-by-a-non-holder into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 21:25:27 +00:00
jochen 557760e537 Issue 218: a mesh seat is answered by a module on a machine that does not hold it 2026-10-03 23:25:17 +02:00
mesh-admin 6b1ebd1d4a Merge pull request 'Issue 217: a refused announcement took down every container's runtime, and the console with it' (#336) from issues/217-a-refused-announcement into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 21:20:06 +00:00
jschoubben 8184585213 ADR 0199: a module that answers names declares its zone, and a node's hosts file is one module's
The per-node resolvers 0194 retires held two kinds of names that are neither nodes nor routes: the
lab's scenario machines and an operator's own lines. A zone a module declares is forwarded by the
mesh's resolver to that module; /etc/hosts is held per node through node-hosts-file, the operator's
lines in its kept region. Research 023 parks seats that define what their holder owns.
2026-10-03 23:19:36 +02:00
mesh-admin fa73a17ceb Merge pull request 'Issues 211, 214, 215, 216 diagnosed' (#335) from issues/211-214-216-diagnosed into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 21:19:03 +00:00
jochen 08cea893bd Issue 217: a refused announcement took down every container's runtime, and the console with it 2026-10-03 22:47:28 +02:00
jochen 04625d3e35 Issues 211, 214, 215, 216 diagnosed: root causes and the branches that fix them 2026-10-03 22:28:40 +02:00
mesh-admin d7bb24b181 Merge pull request 'ADR 0198: a module's long-running code is launched by the node's runtime and reaches the bus through it' (#334) from decision/0198-a-modules-long-running-code-is-launched-by-the-runtime into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 20:21:04 +00:00
jochen 23d6e30b8a ADR 0198: a module's long-running code is launched by the node's runtime and reaches the bus through it; research 022; design 38 WP4c plan 2026-10-03 22:20:53 +02:00
mesh-admin 2043e90f35 Merge pull request 'Issues 214–216: a plan losing its own controller, a module pinned silently, a bundle never delivered' (#333) from issues/214-216-found-building-0192-0193 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 20:15:30 +00:00
jochen c9418af42e Issues 214–216: a plan losing its own controller, a module pinned silently, a bundle never delivered 2026-10-03 22:15:19 +02:00
mesh-admin 9c3e77999e Merge pull request 'Design 38 WP4d: every served bundle launched and node-tools in Go, proven live on all four machines' (#332) from design/38-wp4d-proven into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 20:07:30 +00:00
jochen 6943843fff Design 38 WP4d: every served bundle launched and node-tools in Go, proven live on all four machines 2026-10-03 22:07:20 +02:00
mesh-admin 34325d3566 Merge pull request 'ADR 0197: every tool announces itself on the bus, in the NATS services protocol' (#331) from decision/0196-every-tool-announces-itself into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 20:04:12 +00:00
jochen fa9e94d863 Renumber to ADR 0197: 0196 landed first on main 2026-10-03 22:03:58 +02:00
jochen 1b34821aa0 ADR 0196: every tool announces itself on the bus in the NATS services protocol 2026-10-03 22:03:34 +02:00
jschoubben 50ddaf8408 Merge pull request 'ADR 0196: a node asks the mesh's resolver first, and a public one only when it is silent' (#330) from decision/0196-nodes-ask-the-mesh-resolver-with-a-public-fallback into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 20:01:05 +00:00
jschoubben f5d518d256 ADR 0196: a node asks the mesh's resolver first, and a public one only when it is silent
ADR 0194 rejected sending every query to the mesh's resolver because a node with its tunnel down
would resolve nothing; a public resolver listed second answers exactly then. That drops the
systemd-resolved stub and the runtime's dns: containers copy the machine's resolvers. Narrows 0194;
amends connectivity §2.
2026-10-03 21:56:33 +02:00
jschoubben 577ddf0089 Merge pull request 'ADR 0194: the mesh has one resolver, and every node asks it for the mesh's names' (#326) from decision/0194-the-mesh-has-one-resolver into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 19:51:04 +00:00
jschoubben 13e28e6873 ADR 0194: the no-copies check allows each node's loopback stub 2026-10-03 21:51:03 +02:00
jschoubben 6b6ff76a19 ADR 0194: why every node needs a stub, and the systemd-resolved module that provides it 2026-10-03 21:50:33 +02:00
mesh-admin 8d5e6ef76f Merge pull request 'ADR 0195: the mesh's tools are found by address, not announced whole; research 021; to-be 34 §3a' (#329) from decision/0195-the-mesh-tools-are-found-by-address into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 19:49:37 +00:00
jochen 77813f4613 ADR 0195: the mesh's tools are found by address, not announced whole; research 021; to-be 34 §3a 2026-10-03 21:49:26 +02:00
mesh-admin 4ee8e3905d Merge pull request 'Issue 213: the controller is a Go program and still runs in a container' (#328) from issues/213-the-controller-runs-in-a-container into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 19:42:31 +00:00
jochen 216faec69e Issue 213: what the container gives it, measured 2026-10-03 21:42:22 +02:00
jochen e36b1a9e9c Issue 213: the controller is a Go program and still runs in a container 2026-10-03 21:42:11 +02:00
mesh-admin 5886969c75 Merge pull request 'Issue 212: a toolchain rebuild keeps the SDK it cached' (#327) from issues/212-a-toolchain-keeps-a-cached-sdk into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 19:24:49 +00:00
jochen 5fa43ff755 Issue 212: a toolchain rebuild keeps the SDK it cached 2026-10-03 21:24:39 +02:00
jschoubben 1de4a5f25e ADR 0194: the mesh has one resolver, and every node asks it for the mesh's names
Every resolution fault found on 2026-10-03 was a per-node copy disagreeing with the truth: a hosts
file read once, an operator's old line beside the mesh's, a node's resolver lent to a LAN. Every
tunnel already converges on one node. Retires node-dns-resolver for a mesh-scoped mesh-resolver;
nodes route only the mesh's suffix to it. Narrows 0121; amends connectivity §2 and the seats.
2026-10-03 21:21:49 +02:00
jschoubben 8a1fa37dce Merge pull request 'ADR 0191: domains are a node's — the resolver holds each node's internal domain, nothing else' (#323) from decision/0191-names-by-origin into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 19:12:26 +00:00
mesh-admin 44eb13acd0 Merge pull request 'ADR 0193: every bundle the runtime serves is launched, and the runtime knows no language; design 38 WP4d' (#325) from decision/0193-every-served-bundle-is-launched into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 19:05:12 +00:00
jochen 6d53f9168a ADR 0193: every bundle the runtime serves is launched, and the runtime knows no language; design 38 WP4d 2026-10-03 21:05:03 +02:00
mesh-admin 6a1fc71a4c Merge pull request 'Design 38 WP4b: four tools-only modules proven live from the runtime; two delivery traps' (#324) from design/38-wp4b-seven-proven into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 14:13:40 +00:00
jochen fb76fb7256 Design 38 WP4b: four tools-only modules proven live from the runtime; two delivery traps 2026-10-03 16:13:32 +02:00
jschoubben 2344bfb69b ADR 0191: domains are a node's — one internal, one or more public; the roster is the machines 2026-10-03 16:10:38 +02:00
jschoubben ca8a865e73 ADR 0191: the mesh's names are known by where they were composed, not by their suffix
A progressive insight: the rule and its check were stated as a suffix test; the mesh composes both
names of a route and publishes its internal one. The decision is unchanged.
2026-10-03 15:39:07 +02:00
mesh-admin 27c6881287 Merge pull request 'Issue 211: a bundle is built before the toolchain it is compiled in; ADR 0192 progressive insight; design 38 WP4b built, WP4c opened' (#322) from issues/211-and-0192-insight into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 13:36:43 +00:00
jochen f8458d6f2c Issue 211: a bundle is built before the toolchain it is compiled in; ADR 0192 progressive insight; design 38 WP4b built, WP4c opened 2026-10-03 15:36:26 +02:00
mesh-admin c5778f4366 Merge pull request 'ADR 0192: a tools bundle declares what it is given, and the runtime hands it to that bundle alone; research 020 graduated; design 38 WP4b' (#321) from decision/0192-what-a-bundled-tool-is-given into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 13:18:40 +00:00
jochen 4c1ad0ed45 ADR 0192: a tools bundle declares what it is given, and the runtime hands it to that bundle alone; research 020 graduated; design 38 WP4b 2026-10-03 15:18:22 +02:00
jschoubben 9873e951a9 Merge pull request 'ADR 0191: the mesh resolves only its own names; a public name resolves publicly' (#320) from decision/0191-the-mesh-resolves-only-its-own-domain into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 13:16:13 +00:00
jschoubben e5e6e56ecf ADR 0191: the mesh's resolver holds only the mesh's own names; a public name resolves publicly
Publishing every routed public name at a private address turned ace's LAN-facing resolver into an
outage for non-members: a phone got the control-node's tunnel address for the mail server. Routes
have internal names since 0151 and the proxy certifies public names publicly, so nothing needs the
private answer. Narrows 0066 and 0151; amends connectivity §2 and §5.
2026-10-03 15:11:45 +02:00
mesh-admin 65c30c576a Merge pull request 'Research 020: what a bundled tool is given; design 38 WP4: fail2ban followed, proven live' (#319) from research/020-what-a-bundled-tool-is-given into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 13:11:24 +00:00
jochen ee17cddb74 Research 020: what a bundled tool is given; design 38 WP4: fail2ban followed, proven live 2026-10-03 15:11:08 +02:00
mesh-admin 22e96e5bc7 Merge pull request 'Issue 210 resolved by mesh-host #80: an unchanged process keeps its record' (#318) from issues/210-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 11:56:30 +00:00
jochen 7e464e3b22 Issue 210 resolved by mesh-host #80: an unchanged process keeps its record 2026-10-03 13:44:49 +02:00
mesh-admin 502763ce5e Merge pull request 'Issue 209: a bundle's own SDK copy registers into a registry the runtime never reads; design 38 WP4 note' (#316) from issues/209-a-bundles-own-sdk-copy-registers-nowhere into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 11:20:38 +00:00
jochen eaae0e2b80 Merge remote-tracking branch 'origin/main' into issues/209-a-bundles-own-sdk-copy-registers-nowhere 2026-10-03 13:20:33 +02:00
mesh-admin 2ee6eab7b9 Merge pull request 'Issue 210: the host re-creates the node's runtime on every reconcile, restarting it every ten minutes' (#317) from issues/210-the-host-re-creates-the-runtime-every-cycle into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 11:20:27 +00:00
jochen ce5f85f65e Design 38 WP4 built and proven live on all four machines; issue 209 proven live 2026-10-03 13:20:12 +02:00
jochen 911125148c Issue 210: the record of the runtime's process carries no digest; the credential and the unit text are ruled out 2026-10-03 13:13:25 +02:00
jochen d718a917e5 Issue 210: the host re-creates the node's runtime on every reconcile, restarting it every ten minutes 2026-10-03 13:11:17 +02:00
jochen a572868333 Issue 209 resolved by mesh-tools #32 2026-10-03 13:07:41 +02:00
jochen 55443b67e6 Design 38 WP4: what the review of mesh-catalog #239 found — the credential goes, iptables is declared, escalation is an unchecked machine fact 2026-10-03 13:00:35 +02:00
jochen 89a202f12e Issue 209: a bundle's own SDK copy registers into a registry the runtime never reads; design 38 WP4 note
Found preparing WP4, before the first module's bundle was loaded beside the runtime's own:
proven with a two-copies probe, located in mesh-tools (node-tools), fixed by mesh-tools #32.
Design 38 WP4 records it and the two things the package left to the module — escalation
through sudo, and the filter file read from the manifest's path rather than a container env.
2026-10-03 12:46:51 +02:00
mesh-admin b342c9c3da Merge pull request 'Issues 203, 206 and 207 resolved by mesh-controller #233; 207's diagnosis, with its two questions left open' (#315) from issues/203-206-207-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 09:50:56 +00:00
jochen 336b8c9b62 Issues 203, 206 and 207 resolved by mesh-controller #233; 207's diagnosis, with its two questions left open 2026-10-03 11:50:20 +02:00
mesh-admin 0c82909845 Merge pull request 'Issues 204 and 205 resolved: mesh-controller #232 and mesh-host #79 merged' (#314) from issues/204-205-resolved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 09:46:22 +00:00
jochen d8a0c1b02f Issues 204 and 205 resolved: mesh-controller #232 and mesh-host #79 merged 2026-10-03 11:45:19 +02:00
mesh-admin b232b44f4c Merge pull request 'Design 18 and ADR 0190: shared build work proven live on all four machines, and the five gaps the switch found' (#313) from design/18-shared-build-work-proven-live into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 09:44:19 +00:00
jochen c03f2cd4c6 Design 18 and ADR 0190: shared build work proven live on all four machines, and the five gaps the switch found 2026-10-03 11:43:53 +02:00
mesh-admin 73c4d24024 Merge pull request 'Issues 203–206 diagnosed and located' (#310) from issues/203-206-diagnosed into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 09:43:00 +00:00
mesh-admin 3372d72da0 Merge pull request 'Issue 208: a seat's worker is made only when the controller starts, so a holder assigned later finds none' (#312) from issues/208-a-holder-assigned-later-finds-no-worker into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 09:33:28 +00:00
jochen 273b932329 Issue 208: a seat's worker is made only when the controller starts, so a holder assigned later finds none 2026-10-03 11:33:08 +02:00
mesh-admin f91a3efb6b Merge pull request 'Issue 207: a re-made worker replayed every ask the stream kept, and the mesh re-registered its past' (#311) from issues/207-a-re-made-worker-replayed-history into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 09:06:17 +00:00
jochen 2f0ce4ce19 Issue 207: a re-made worker replayed every ask the stream kept, and the mesh re-registered its past 2026-10-03 11:05:40 +02:00
jochen bf3338898e Issue 204: diagnosed and located — a declaration was numbered after composing, and a dying sender recorded nothing 2026-10-03 04:03:51 +02:00
jochen 4891cdeac5 Issues 203, 205, 206: diagnosed and located 2026-10-03 03:51:41 +02:00
mesh-admin 733cff4c3c Merge pull request 'Issue 206: a seat's worker changing type strands its holder, and the build that would fix it' (#309) from issues/206-a-worker-changing-type-under-its-holder into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 01:46:12 +00:00
jochen 561f26fb34 Issue 206: a seat's worker changing type strands its holder, and the build that would fix it 2026-10-03 03:45:33 +02:00
mesh-admin d8a58dadcf Merge pull request 'ADR 0190: a seat's work is shared by its holders, and building is the first such role; design 18 says where a build runs' (#306) from decision/0190-a-seats-work-is-shared-by-its-holders into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 00:44:27 +00:00
mesh-admin 3f4782cfb4 Merge pull request 'Design 38: WP3 proven live on all four machines; the three issues it found' (#308) from design/38-wp3-proven-live into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 00:41:47 +00:00
jochen d076647b5d Design 38: WP3 proven live on all four machines; the three issues it found 2026-10-03 02:41:24 +02:00
mesh-admin ae83c5e09b Merge pull request 'Issues 203–205: what the first node-tools rollout found' (#307) from issues/203-205-what-the-first-node-tools-rollout-found into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-03 00:41:07 +00:00
jochen feeea127e2 Issues 203–205: what the first node-tools rollout found — an unissued credential pushed, a stale declaration during a controller handover, a package resource against a stale database 2026-10-02 23:47:12 +02:00
jochen 76b563e0ce ADR 0190: the ack-wait race is issue 175 2026-10-02 22:29:53 +02:00
jochen f56686d1e5 ADR 0190: a seat's work is shared by its holders, and building is the first such role; design 18 says where a build runs
The build role was a mesh seat with one holder and its worker a push consumer with one delivery in
flight, so thirty-five images rebuilt serially on one machine while three others idled. The bus was
drawn for the alternative — a seat's accept subjects on a queue group of holders (design 25) — and
0190 uses it as one pattern for every role: a node seat with accepts, every holder pulling one ask
when idle, the asker addressing the role. Building is the first use: node-build-agent, held by the
build-agent module on every machine with a container runtime. Notes in 0121 and 0162 where their
facts went stale.
2026-10-02 22:29:08 +02:00
mesh-admin 5938d40dee Merge pull request 'ADRs 0164–0166 and issue 190: the container runtime gets a module, a seat and declared settings' (#271) from decision/docker-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 20:14:58 +00:00
mesh-admin f062672f83 Merge pull request 'Issues 192 and 193: the console reaches a person only by hand; the store's read-only query is not' (#272) from issues/192-193-console-registration-and-store-query into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 20:14:46 +00:00
jschoubben e4a0c73e2b Merge remote-tracking branch 'origin/main' into issues/192-193-console-registration-and-store-query 2026-10-02 22:14:24 +02:00
jschoubben d1aeee42a4 Regenerate the decision index after merging main 2026-10-02 22:14:21 +02:00
mesh-admin 81d780f973 Merge pull request 'Design 38: WP3 built — node-tools beside mesh-tools, the three things the plan did not say, and the gate refuses spreading not standing' (#304) from design/38-wp3-built-and-the-gate-softened into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 19:57:16 +00:00
jochen 3e30846e0f Design 38: point at ADR 0069's real file name 2026-10-02 21:46:34 +02:00
jochen b3f18c54c6 Design 38: WP3 built — node-tools beside mesh-tools, the three things the plan did not say, and the gate refuses spreading not standing 2026-10-02 21:46:01 +02:00
mesh-admin e11bf320c9 Merge pull request 'ADR 0188: a module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime; design 38 gains WP1b' (#300) from decision/0187-a-modules-own-code-is-bundles-in-any-language into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 19:27:39 +00:00
jschoubben da8b4b4ee4 Renumber to ADR 0188: 0187 landed on main first, as the dead-tracker record
Two records shared 0187 (issue 155's collision); the branch landing last
renumbers, and this is it. Only the number changes.
2026-10-02 21:01:02 +02:00
jschoubben c026d5221e Merge remote-tracking branch 'origin/main' into renumber-0187 2026-10-02 21:00:45 +02:00
mesh-admin 983fd412c6 Merge pull request 'ADRs 0180, 0186 and 0187: their live rows, done' (#302) from docs/the-four-fixes-proven-live into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 18:46:49 +00:00
jschoubben 9ac2493e2c ADRs 0180, 0186 and 0187: their live rows, done — all four machines filtered by the mesh alone, both front ends removed, no machine wrong or behind 2026-10-02 20:46:35 +02:00
mesh-admin 560f25c2c7 Merge pull request 'ADR 0187: a dead tracker is not the machine's failure' (#301) from fix/a-dead-tracker-is-not-the-meshs-failure into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 18:42:01 +00:00
jschoubben 9e0288128b ADR 0187: a dead tracker is not the machine's failure; design 32 2026-10-02 20:41:30 +02:00
jochen 709240ec1f ADR 0187: a module's own code is bundles in any language, and a tools bundle speaks MCP to the runtime; design 38 gains WP1b
The operator's direction, absent from every record until now: the SDK must not limit who writes a
module; tools and services may be written in any language; one module may ship several bundles
(tools, a seat's implementation, a daemon); skeleton first, a full implementation when the work
requires it. ADR 0175 had the runtime import a bundle, which only JavaScript can be.

0187 makes a tools bundle a process the node's runtime launches and speaks MCP over stdio to —
the vocabulary the runtime already speaks outward — so any language with an MCP library can write
one today and the mesh's SDK per language is thin; the transport stays in the runtime (0039's
refusal, kept). Importing a TypeScript bundle is the shortcut, not the contract. Notes in 0175,
0039 and 0150 say where their mechanism moved; design 38 records WP1 as built and adds WP1b (the
launcher and the skeleton SDKs); the glossary's bundle widens.
2026-10-02 18:56:57 +02:00
mesh-admin d57289e049 Merge pull request 'ADR 0186: a ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang' (#299) from fix/a-ban-list-never-holds-a-neighbour into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 16:43:32 +00:00
jschoubben d4a2f99ab5 ADR 0186: a ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang; design 31 2026-10-02 18:42:16 +02:00
mesh-admin a9f91fdd0c Merge pull request 'ADRs 0184 and 0185: a service is still running a moment later; a control plane behind its row serves what it can' (#298) from fix/a-service-asked-to-run-is-still-running into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 16:25:44 +00:00
jschoubben 131a5e4714 Issue 201: what was established about the race while closing the outage half 2026-10-02 18:19:12 +02:00
jschoubben 329a24fdae ADRs 0184 and 0185: a service is still running a moment later; a control plane behind its row serves what it can; issue 201 half closed 2026-10-02 18:16:24 +02:00
mesh-admin 7f72f3b79a Merge pull request 'ADR 0179: the intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail' (#295) from feat/the-intrusion-seat-serves-its-verbs into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 15:28:48 +00:00
jschoubben 114a71f36f ADR 0179: built and proven live; the one fault the machine found, and the check that refuses it 2026-10-02 17:28:38 +02:00
jschoubben 0f407417f3 Merge main: the ufw record renumbered to 0180, and ADR 0175 retires the per-module tool runtime this one ships 2026-10-02 17:28:23 +02:00
mesh-admin 4af731df19 Merge pull request 'The operator's agent and its licence manager are modules: ADR 0181–0183, to-be 36 and 39' (#286) from feat/claude-code-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 15:23:38 +00:00
jochen 7b1dabbce0 The operator's agent and its licence manager are modules: ADR 0181–0183, to-be 36 and 39
The predecessor's agent module was retired and its six files stayed on both workstations telling
every session to use tools that no longer exist. This is its successor's design, revised during
review on the operator's directions: the host is module-agnostic, the controller has no part, and a
real licence manager hands out the correct licence in every situation.

- ADR 0181 (reconstructed): the operator account is a node fact stated by the operator; the home is
  derived unless stated; a resource may be placed under it owned by the account; a node with no
  account refuses one. What the controller shipped on 2026-09-27 without a record.
- ADR 0182: inside a home the module owns the directory and the files it places, writes into the
  tool's own files for its few keys, never declares a credential's content, and holds everything
  else as found; a predecessor's leftovers are the operator's to remove once.
- ADR 0183: claude-licence-manager holds the mesh seat anthropic-licence-manager and owns the
  Anthropic licences, grants (encrypted with a key the vault made for it), bindings per touchpoint,
  usage and audit; one rotation source under a lease; a token travels module to module sealed to
  each node's module key on request/reply, never as an event; the agent module alone writes what
  the agent reads; the host delivers package and state and knows nothing else. A bounded exception
  to ADR 0113; dated mechanism notes on ADR 0050 and 0113.
- To-be 36 (claude-code): the mesh's part of the agent's configuration lives in the agent's
  machine-wide managed directory, owned whole by the module and written by its code; nothing under
  the home but the credentials file of a subscription licence; the API-key licence through the
  key-helper; the console as a node-scoped provision (to-be 34 amended); MCP servers as settings
  with an mcp_configure tool; one agent directory per machine shared by every session.
- To-be 39 (claude-licence-manager): store, the two licence kinds, keeping a grant alive, the
  hand-over, who gets which licence with the predecessor's fallbacks, adoption with the identity
  guard, the seat's verbs.

Numbers taken across main and every open branch at the time of the merge; to-be 14, 29 and 34
carry dated notes; the glossary gains "operator account".
2026-10-02 17:22:12 +02:00
mesh-admin 2caa5e827b Merge pull request 'To-be 38: building the operator's machine as work packages; ADR 0175 collision renumbered to 0180' (#296) from feat/the-operators-machine into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 15:20:38 +00:00
jochen 179fd7f83f To-be 38: building the operator's machine as work packages; ADR 0175 collision renumbered to 0180
The runtime work of design 37 broken down the way design 28 broke down the
bus: what exists measured (the host needs no change — a process and an
archive are what the runtime and a bundle are; the runtime does the job for
one module and must do it for a list), the order the dependencies allow, and
eight packages each ending at a proof on the live mesh — the lab skipped by
the operator's decision, ADR 0149 cited. WP1 the runtime serves many modules;
WP2 the controller composes one per node (a node principal, bundle archives,
the runtime's process, the gate); WP3 the runtime is a module and the console
its serving mode; WP4 the packet filter moves first; WP5 the shell on a
server; WP6 the service manager on a workstation; WP7–8 named and not broken
down.

Main carried two records numbered 0175 and cycle.py refused it: the one that
landed last (the found front end is uninstalled) takes 0180, the next free
across main and the open changes, with a dated note; design 08's citation
follows it; the index is regenerated.
2026-10-02 17:20:14 +02:00
jschoubben d0d5799884 Issue 201: a push recreated the controller at a digest older than the seat row its successor wrote 2026-10-02 17:15:22 +02:00
jschoubben 9ba4de5557 ADR 0179: the intrusion seat serves its verbs, a container may log to the journal, and every door declares its jail; designs 31 and 33 2026-10-02 17:02:49 +02:00
mesh-admin e1203e5a43 Merge pull request 'Research 019: a warm twin of the running mesh' (#294) from jschoubben/research-019 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 14:59:44 +00:00
jschoubben 4ce967619a Research 019: a warm twin of the running mesh in the lab 2026-10-02 16:59:36 +02:00
mesh-admin 6df2cfecd6 Merge pull request 'Research 018 graduates: ADRs 0173–0177 and to-be 37, the operator's machine' (#293) from feat/the-operators-machine into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-02 14:58:48 +00:00
jochen 73047501f6 ADR 0154: the controller seat gains a generic command verb (dated note, by ADR 0175) 2026-10-02 16:53:15 +02:00
jochen bf39baf104 Research 018 graduates: ADRs 0173–0177 and to-be 37, the operator's machine
Every configurable thing on a node is a module, the home included, and a
module is whatever it declares (0173, extending 0040). A node varies a module
only through a setting rendered into the file or a kept region, never an edit
(0174, extending 0011; issue 168 first). One tool runtime per node serves every
module's tools on the host side, never in a container; the console is its
serving mode, renamed node-tools (0175, extending 0150; 0047/0150/0152 carry
dated notes). The login shell is a node seat held by one shell module with
`execute` as its contract (0176). A unit may be user-scoped and the service
manager is a node seat held by systemd (0177).

To-be 37 is handed off in-progress to mesh-host, mesh-controller, mesh-tools
and mesh-catalog, with the build in order: the account on every node, the
runtime, zsh, systemd, then the graphical stack. To-be 29 keeps ~/.ssh and
points at 37; 33 §6 and 34 are amended; the glossary gains node tools, bundle,
kept region, installed/holding, and retires flavor.
2026-10-02 16:34:57 +02:00
jschoubben 17ca9a262b 0164: a setting names the file it lands in (issue 198's leak between one module's files); 190 notes the fourth machine now has the resolver 2026-10-02 12:23:28 +02:00
jschoubben 967c793eaa Merge remote-tracking branch 'origin/main' into decision/docker-module 2026-10-02 12:23:27 +02:00
jschoubben 27c1db8a86 Review of 0164-0166 and 190: the mesh's own setting words stay settable; changing runtime verbs are not the console's wildcard; migration steps 1-2 are one push; dnsmasq's dns key dates from 09-23 2026-10-02 00:48:06 +02:00
jschoubben 24aeb203f7 Issue 193 resolved: both readers live on every machine, checked by asking each copy who it is 2026-10-02 00:35:03 +02:00
jschoubben afbfd5f29d Issue 193: mssql's variable substitution and shell commands, proven and fixed by mesh-catalog PR 210 2026-10-02 00:25:48 +02:00
jschoubben 696957aa5e Issue 193: proven on a throwaway server, fixed for postgres by mesh-catalog PR 209; mssql has the same hole 2026-10-02 00:09:38 +02:00
jschoubben 3d54fcbb86 Merge remote-tracking branch 'origin/main' into decision/docker-module 2026-10-02 00:02:57 +02:00
jschoubben b13ef1be81 Issues 192 and 193: the console reaches a person only by hand; the store's read-only query is not
192: no provision says where the console is, its port was never assigned, and nothing
owns a person's agent configuration since the predecessor left. 193: the query verb wraps
the caller's text in a read-only transaction the text can end, and its rows come back
keyed by BEGIN.
2026-10-02 00:02:51 +02:00
jschoubben f1941304cc ADRs 0164-0166 and issue 190: the container runtime gets a module, a seat and declared settings
Proposed for the operator's review: settings declared with defaults and cost (0164),
container-runtime as a kernel capability (0165), node-container-runtime seat with the
host creating containers through its holder (0166), and the runtime's file written by
modules that are not its own (190).
2026-10-01 23:13:18 +02:00
546 changed files with 50704 additions and 863 deletions
+26 -5
View File
@@ -21,9 +21,28 @@ Opens a numbered issue. **Authoritative playbook:**
there, this is not an issue — it is a lookup. If the answer is a general lesson, it belongs in
both.
## The number comes from the issue tracker
Every issue is opened in the issue tracker (seat `issue-tracker`, module `mesh-issues`, ADR 0275). **Never take a
number by reading `main` or the open pull requests** — that is how numbers collided. If the tracker cannot be
reached, take no number and say so.
Decide the shape first:
- **Design or governance issue** — its narrative belongs in hq: it gets a folder `04-ISSUES/NNN-short-name/`.
- **Anything else** — it lives wholly in the tracker, and nothing is written in hq.
## Steps
1. Next free number. Create `04-ISSUES/NNN-short-name/00-report.md` with:
1. Load `mesh_call` and `mesh_describe` (deferred tools of the `mesh` MCP server) and open the issue:
`mesh_call issue-tracker.open` with `title` (the symptom in one line), `report` (what was seen, what was expected,
how it was found — the evidence: what was run, what came back, when), optionally `located-in` when the owner is
already known, and — **only for an issue with a folder** — `record: short-name` (lower-case words joined by
hyphens, no number). The answer carries the number `NNN`.
2. **Issue wholly in the tracker:** done. Its trail goes in `issue-tracker.note`, its status changes through
`issue-tracker.move`, its links through `issue-tracker.link`.
3. **Issue with a folder:** on a branch off `origin/main`, create `04-ISSUES/NNN-short-name/00-report.md`, `NNN` being
exactly the tracker's number and `short-name` exactly the `record` passed:
```yaml
---
@@ -35,13 +54,15 @@ both.
---
```
2. Write the symptom **as observed**, in plain terms, with the evidence that it happened —
what was run, what came back, when.
3. Say why it matters beyond the instance. An issue that is only one occurrence is a knowledge
Its status stays in this frontmatter until slice 3 of to-be 53 imports the folders (ADR 0275);
`issue-tracker.move` refuses it until then. Notes and links may still go to the tracker.
4. Write the symptom **as observed**, in plain terms, with the evidence that it happened.
5. Say why it matters beyond the instance. An issue that is only one occurrence is a knowledge
base entry.
4. End with open questions rather than a proposed fix. Diagnosis is a separate step.
6. End with open questions rather than a proposed fix. Diagnosis is a separate step.
## Do not
- Do not name nodes, domains, addresses or paths. This repository is public.
- Do not guess the owner — `located-in:` is filled by diagnosis, not by opening.
- Do not renumber a folder or reuse a number: a number never moves.
+9 -3
View File
@@ -23,8 +23,12 @@ generated on demand and never written back to disk.
1. Read the frontmatter block from every file above — a grep across each tree is enough, no
need to load bodies.
2. Render what was asked as Markdown tables. Group and sort sensibly. The **ADR index** is one
of these views: records ordered by number, with title, date and status, and reconstructed
ones marked.
of these views, and it is the only place the list of records is shown
([ADR 0248](../../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)):
run `python3 00-META/checks/index.py --print` for the records grouped by topic in the reading
order `02-DECISIONS/README.md` writes, each with its status when not `accepted`. Asked for it by
number instead, render records ordered by number, with title, date and status, and reconstructed
ones marked. Off this checkout, the records module's `records_decisions` answers the same list.
3. Flag anything inconsistent at the end, as flags — do not silently correct the render:
- a `graduated` or `abandoned` effort with an empty `became:`
- an `implemented` design with an empty `code:`
@@ -37,5 +41,7 @@ generated on demand and never written back to disk.
- **Do not write a status file.** Central status files are explicitly rejected. The view is
always generated, always ephemeral. This includes the ADR index — the hand-written one had
already drifted after a single addition, which is why it was removed.
already drifted after a single addition, which is why it was removed, and the generated one
written into `02-DECISIONS/README.md` made every two open decisions conflict (issue 297), which
is why that went too. `index.py` fails if the list is stored there again.
- Do not infer status from prose. Trust only the frontmatter; if it is wrong, flag it.
+1
View File
@@ -0,0 +1 @@
__pycache__/
+53 -5
View File
@@ -2,8 +2,11 @@
```
python3 00-META/checks/records.py structure: links, citations, supersession, topics
python3 00-META/checks/index.py the reading order in 02-DECISIONS/README.md is current
python3 00-META/checks/index.py --write regenerate it
python3 00-META/checks/index.py every record's topic is in the reading order, and no list of records is stored
python3 00-META/checks/index.py --print the records, by topic, in reading order (generated, never written)
python3 00-META/checks/words.py the glossary's retired words are not used, no daemon's line is written, and no word is defined twice
python3 00-META/checks/words.py --list tools the words the catalogue's copy must list
python3 00-META/checks/words.py --list lines the daemons' lines the catalogue's copy must list
```
Non-zero exit on any problem, so it can be a gate rather than a report.
@@ -24,8 +27,8 @@ indistinguishable from one that cannot.
| `live-citation` | a governing document citing a **superseded** record names its replacement in the same paragraph | `01-to-be/README.md` citing ADR 0022 as live guidance |
| `supersession` | if A says it was superseded by B, B says it supersedes A | ADR 0012 never declared that it superseded 0011 |
| `numbering` | the number in the filename is the number in the heading | — |
| `topics` | every record names a topic the index knows | — |
| *(index.py)* | the written reading order matches what the records say | — |
| `topics` | every record names a topic of the reading order in `02-DECISIONS/README.md` | — |
| *(index.py)* | the reading order is written, every record's topic is one of it, and the README stores no list of records ([ADR 0248](../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)) | a stored list made every two open decisions conflict ([issue 297](../../04-ISSUES/297-two-open-decisions-always-conflict/00-report.md)); it failed on the README of `main` before the list was removed |
| `status-vs-code` | a to-be document naming specific code is not still `designed` | **ten documents**, several with a *What was built* section, describing lab-proven code |
## What is deliberately not checked
@@ -56,4 +59,49 @@ a to-be design names a decision, an in-progress/implemented design names its own
located/fixed issue names its owner, a fixed/resolved issue says what fixed it, a graduated
research overview says what it became, and no two issue records share a number (issue 155 — the
number is how a record is cited, and `main` lags every open pull request, so two people reading it
allocate the same one). `python3 00-META/checks/cycle.py`
allocate the same one). And a core issue — one opened from 2026-10-07 whose `located-in` names a core
repository — resolves only with `replay:` (an id in mesh-lab's replays register) or `replay-none:` saying
why none is possible ([ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md));
it failed on a resolved core issue carrying neither before it passed. And an accepted record that puts work
off — "until renamed", "left for later", "not decided here", "postponed", "future work", "deferred", "a later
change" — names the issue that tracks it, as tracking it ("tracked by issue N", or "issue N tracks"), in that
paragraph or list item, or in a dated progressive insight that speaks about it: one in the same section, or one
that quotes it. An issue cited for another reason tracks nothing, and one insight does not excuse every deferral
in a record. A rejected option is not work put off and is not held, judged by its `##` section, so an option under a
`###` subheading of "Considered Options" is skipped too
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md):
ADR 0244 deferred the code renames with no issue, and two days later the operator believed them done). Held to
it: a record dated from 2026-10-09, and an older one corrected under the rule. Measured from ADR 0244's date
it found deferrals without an issue in ADR 0241, 0244 and 0270; held from 2026-10-09 it failed on 0270 and 0273
(whose "not decided here" sits beside a citation of issue 340 that tracks none of it), and on 0244's four
deferral places once its insight brought it under the rule — on three of them while the insight, in §6, named
the issue without quoting them — before each named its issue (355, 357, 354). `python3 00-META/checks/cycle.py`
## words.py
One word per thing, checked ([ADR 0244](../../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)).
It reads the glossary's *Not:* lines (the retired words, each with its scope) and *Identifier until
renamed* lines, and fails on a retired word or a bare identifier in running prose — what is left once code,
quotations, struck-through text, link targets, comments and frontmatter are taken out — in `00-META/`,
`03-DESIGN/`, `AGENTS.md`, `README.md`, and research and issues dated from 2026-10-07. Decision records are
never checked. It also fails when a glossary head word heads two entries or is also retired.
[`words-allowed.md`](words-allowed.md) names a document that could not be reworded at once, with a date, or
a graduated research effort kept as written. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
of the tools' retired words (`retired-words`) with the glossary.
It also fails on a **daemon's line** ([ADR 0272](../../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md),
its insight of 2026-10-09): the operator types one command, `nox`, and a document names a `nox` line, never the
controller's or the node-engine's binary followed by one of its commands. The lines are the check's own list
(`DAEMON_LINES`); a command that is a line only with an argument after it is matched as a line is written. Looked
for in the same documents, from 2026-10-09 for research and issues, in the document's own words — code blocks and
spans included, quotations and block quotes taken out. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
(`retired-lines`) with the list.
It failed on something real before it passed: 581 uses of retired words in 63 documents on its first run, besides the glossary itself and research 034 —
"the host" for the node-engine in 30 designs, "control plane" in 10, the glossary's own entry for the tool runner —
and two glossary contradictions (the "console", the deprecated broker's seat), fixed in the change that
added it. A retired word's plural (`s` or `es` on its last word) is matched as the word: until 2026-10-07
it was not, so "alerts", "rollouts" and "the hosts" (the node-engines) passed while their singulars
failed; the plural failed on 15 uses in 10 documents before it passed. A verb that reads like a plural
("a node hosts") is a finding too, and is reworded. Homonyms (*plan*, *gate*, *tier*, *ask*, *store*, *record*, *check*) are not checked by it: a
word list cannot tell one sense from another, so they are reviewed.
Binary file not shown.
+126
View File
@@ -16,6 +16,11 @@ What is enforced:
once `resolved`, `fixed-by:` says what fixed it (prose counts --
"nothing, the capability existed" is an answer). And no two records share a
number -- the number is how a record is cited.
replays a core issue -- one opened from 2026-10-07 whose `located-in:` names a core repository --
resolves only with `replay:` (the replay's id in mesh-lab's replays register) or
`replay-none:` (why no replay is possible): ADR 0237, to-be 45 §9. A replay is what
proves a fix fails before and passes after; without one, a fixed class comes back
through a different door (research 031: four such chains in one week).
research a known `status:`; a `graduated` overview says what it `became:`, and every
target it names exists.
decisions every accepted record is REACHABLE from the cycle: cited by a design doc's
@@ -41,6 +46,85 @@ DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abando
ISSUE_STATUSES = {"open", "diagnosing", "located", "resolved", "wontfix"}
RESEARCH_STATUSES = {"active", "graduated", "abandoned"}
# The core, as to-be 45 names it, by where its code lives: the controller, the node-engine, the node
# tools and the console, the SDK's loops, and the catalogue's bus, forge (the announcer of merges) and
# build agent. A `located-in:` entry naming any of these makes an issue a core issue.
CORE = re.compile(r"\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|"
r"mesh-catalog\s+modules/(nats|gitea|build-agent))\b")
# The day the rule began (ADR 0237): issues opened before it are not held to it.
REPLAYS_FROM = "2026-10-07"
REPLAY_ID = re.compile(r"^R\d+\b")
# A decision that defers work names the issue that tracks it (issue 354). ADR 0244 left the code
# renames of the node-engine and the tool runner "for a later rename", with no issue, owner or date;
# two days later the operator believed them done, and nothing in the record said otherwise. A deferral
# nothing tracks is indistinguishable from work that was done. Held to it: an accepted record dated
# from DEFERRALS_FROM, and an older one corrected under the rule (a progressive insight dated from then
# on): an edit made under the rule is held to it. The phrases are the ones records use to put work off;
# "for later" alone is not one, because records say "never queued for later" and "too narrow for later".
# The tracking issue is named as tracking it, beside the deferral or in a dated insight in the same section
# or quoting it (ISSUE_REF, covers).
DEFERRALS_FROM = "2026-10-09"
DEFERRAL = re.compile(r"(?i)until (?:it is |they are |the code (?:is )?)?renamed|until the code rename|"
r"\ba later rename|not renamed here|identifier to rename|"
r"left (?:for|to) (?:a )?later|not decided here|\bpostponed\b|future work|"
r"\bdeferred\b|later work|not built here|\ba later change")
# The issue that tracks a deferral is named as tracking it -- "tracked by issue N", or "issue N tracks" --
# because a record cites issues for many reasons, and an issue cited as context beside a deferral (ADR 0273
# names issue 340 in the very sentence that leaves a channel undecided) tracks nothing.
ISSUE_REF = re.compile(r"(?i)tracked (?:by|in|as)\s+\[?issue (\d{3})|\[?issue (\d{3})\]?(?:\([^)]*\))?\s+tracks\b")
INSIGHT = re.compile(r"\*\*Progressive insights?[ \t]*[—–-][ \t]*(\d{4}-\d{2}-\d{2})")
def paragraphs(path):
"""A record's body as (first line number, section heading, chapter, text) paragraphs: frontmatter and fenced
code left out, a block quote's markers stripped, so a quoted insight reads as the paragraph it is. The
section is the nearest heading above the paragraph; a heading is its own paragraph and its own section.
The chapter is the nearest `#` or `##` heading, which a `###` subheading inside it does not change."""
text = open(path, encoding="utf-8").read()
m = re.match(r"^---\n.*?\n---\n?", text, re.S)
body, first = (text[m.end():], text[: m.end()].count("\n") + 1) if m else (text, 1)
body = re.sub(r"(?ms)^\s*```.*?^\s*```", lambda x: re.sub(r"[^\n]", " ", x.group(0)), body)
out, line, section, chapter = [], first, "", ""
for chunk in body.split("\n\n"):
stripped = "\n".join(re.sub(r"^\s*>\s?", "", l) for l in chunk.split("\n")).strip()
if stripped:
heading = re.match(r"^#{1,6}\s+(.*)", stripped)
if heading:
section = heading.group(1).strip()
if len(stripped) - len(stripped.lstrip("#")) <= 2:
chapter = section
# A list's top-level items are units of their own: a Consequences list is one paragraph, and an
# issue named in one item tracks nothing in the next.
items = re.split(r"\n(?=[-*] |\d+\. )", stripped)
at = line + chunk[: len(chunk) - len(chunk.lstrip())].count("\n")
for item in items:
out.append((at, section, chapter, item))
at += item.count("\n") + 1
line += chunk.count("\n") + 2
return out
QUOTE = re.compile(r"\"([^\"]{12,})\"|\u201c([^\u201d]{12,})\u201d")
def flat(text):
"""Text compared for a quotation: markup and case dropped, whitespace collapsed."""
return re.sub(r"\s+", " ", re.sub(r"[*`_]", "", text)).strip().lower()
def covers(insight_section, insight_text, section, text):
"""An insight speaks about a deferral paragraph when it stands in the same section, or quotes it."""
if insight_section == section:
return True
body = flat(text)
return any(flat(a or b) in body for a, b in QUOTE.findall(insight_text))
def issue_refs(text, issues):
return {n for a, b in ISSUE_REF.findall(text) for n in (a, b) if n and n in issues}
def rel(path):
return os.path.relpath(path, ROOT)
@@ -159,6 +243,16 @@ def main():
bad(path, "status %s but located-in is empty" % status)
if status == "resolved" and not listy(front, "fixed-by"):
bad(path, "status %s but fixed-by says nothing" % status)
# A core issue resolves with its replay, or with why none is possible (ADR 0237).
replay = " ".join(listy(front, "replay"))
if replay and not REPLAY_ID.match(replay):
bad(path, "replay: %r names no replay -- an id from mesh-lab's replays register, R<issue>" % replay)
core = any(CORE.search(entry) for entry in listy(front, "located-in"))
opened = str(front.get("opened") or "")
if (status == "resolved" and core and opened >= REPLAYS_FROM and not replay
and not " ".join(listy(front, "replay-none")).strip()):
bad(path, "a core issue resolved with no replay: name it in `replay:` (mesh-lab replays "
"register.go) or say in `replay-none:` why none is possible (ADR 0237)")
# ---- research ----------------------------------------------------------------------
for path in sorted(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))):
@@ -208,6 +302,38 @@ def main():
bad(path, "an accepted decision nothing in the cycle cites -- give it a home in a "
"design doc's decisions:, a research became:, an issue, or 00-META")
# ---- deferrals -------------------------------------------------------------------
# A paragraph or list item that puts work off names the issue that tracks it, or a dated
# progressive insight that speaks about it (its section, or a quotation of it) does. An issue that has
# since resolved still counts: the deferred work landed, which is what the pointer was for.
for name, (path, status) in records.items():
front = frontmatter(path) or {}
decided = str(front.get("date") or "")
if status != "accepted":
continue
paras = paragraphs(path)
insights = [(d, sec, text) for line, sec, _, text in paras for d in INSIGHT.findall(text)]
held = decided >= DEFERRALS_FROM or any(d >= DEFERRALS_FROM for d, _, _ in insights)
if not held:
continue
# A dated insight naming an issue covers the deferrals it speaks about -- those in its own section,
# or ones it quotes -- and no other: one insight must not excuse every deferral in the record.
tracking = [(sec, text) for d, sec, text in insights if issue_refs(text, seen)]
for line, section, chapter, text in paras:
m = DEFERRAL.search(text)
# A rejected option is a decision not to do it, not work put off -- judged by its chapter, so an
# option under a `###` subheading of "## Considered Options" is skipped as well.
if re.match(r"(?i)(options? rejected|considered options|rejected)", chapter):
continue
if not m or issue_refs(text, seen):
continue
if any(covers(isec, itext, section, text) for isec, itext in tracking):
continue
bad(path, "line %d defers work (%r) and names no issue that tracks it -- a deferral nothing "
"tracks reads as done (issue 354). Name the issue beside it, with its owner, or in a "
"dated progressive insight that says what it tracks"
% (line + text.count("\n", 0, m.start()), m.group(0)))
checked = (
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
+54 -47
View File
@@ -1,16 +1,18 @@
#!/usr/bin/env python3
"""Generate the decision index, and check the written one still matches.
"""The reading order of the decision records: its topics are written, its list is generated.
A number identifies a record and never changes, so the folder listing is creation order rather
than reading order. The index is what carries the path — and it is written rather than only
generated on demand, because a reader on a forge sees the folder and not a command.
than reading order. The reading order is the six topics `02-DECISIONS/README.md` writes down, in
order, and each record's own `topic:`. The list of records under each topic is generated from
that frontmatter when somebody reads it, and never stored (ADR 0248, hq issue 297): a stored
list was a line every decision pull request added to one file, so any two open at once conflicted.
The objection to a written index is that it drifts. That objection is answered by checking it
rather than by refusing to write one, which is `how-we-build` §5: a rule states how it is
checked.
python3 00-META/checks/index.py check
python3 00-META/checks/index.py --print print the list, by topic, in reading order
python3 00-META/checks/index.py --write regenerate it
python3 00-META/checks/index.py fail if it is stale
It fails when the README's reading order is missing, when a record's topic is not one of its
topics, and when the README stores a list of records again — the index markers, or a line of
the generated list. The last one is what keeps the conflict from coming back.
"""
import glob
@@ -20,20 +22,26 @@ import re
import sys
README = "02-DECISIONS/README.md"
START = "<!-- index:start -->"
END = "<!-- index:end -->"
# The reading order. Topics a record may belong to, in the order somebody would learn the system.
TOPICS = [
("the mesh", "What the mesh is"),
("the tiers", "Its tiers, from the bottom up"),
("what runs on it", "What runs on them, and how it gets there"),
("building it", "How it is built"),
("checking it", "How it is checked"),
("how we work", "How we work"),
# A topic in the README's reading order, e.g.
# 1. **What the mesh is** — `topic: the mesh`. What it is for, ...
# The records module's `records_decisions` reads the same line, so the form is a contract.
TOPIC_LINE = re.compile(r"^\d+\.\s+\*\*(?P<label>[^*]+)\*\*\s+—\s+`topic:\s*(?P<topic>[^`]+)`", re.M)
# What a stored list looks like: the old markers, or a line of the list this script prints.
STORED = [
(re.compile(r"<!--\s*index:(start|end)\s*-->"), "an index marker"),
(re.compile(r"^- \*\*\d{4}\*\* — \[", re.M), "a line of the generated list"),
]
def reading_order(text=None):
"""The topics, in order, as (topic, label) pairs, from the README."""
if text is None:
text = io.open(README, encoding='utf-8').read()
return [(m.group("topic").strip(), m.group("label").strip()) for m in TOPIC_LINE.finditer(text)]
def field(text, name):
m = re.search(r'^%s:\s*(.+)$' % name, text, re.M)
return m.group(1).strip() if m else None
@@ -54,10 +62,9 @@ def records():
return out
def render(rs):
known = {t for t, _ in TOPICS}
lines = [START, ""]
for topic, label in TOPICS:
def render(topics, rs):
lines = []
for topic, label in topics:
rows = [r for r in rs if r["topic"] == topic]
if not rows:
continue
@@ -67,40 +74,40 @@ def render(rs):
mark = "" if r["status"] == "accepted" else " *(%s)*" % r["status"]
lines.append("- **%s** — [%s](%s)%s" % (r["number"], r["title"], r["file"], mark))
lines.append("")
stray = [r for r in rs if r["topic"] not in known]
if stray:
lines.append("### Unfiled")
lines.append("")
for r in stray:
lines.append("- **%s** — [%s](%s) — `topic:` is %r, which is not one of %s" % (
r["number"], r["title"], r["file"], r["topic"], ", ".join(sorted(known))))
lines.append("")
lines.append(END)
return "\n".join(lines)
def main():
text = io.open(README, encoding='utf-8').read()
wanted = render(records())
topics = reading_order(text)
rs = records()
if START not in text or END not in text:
print("index: %s has no index markers (%s / %s)" % (README, START, END))
return 1
current = text[text.index(START):text.index(END) + len(END)]
if "--write" in sys.argv:
if current == wanted:
print("index: already current")
return 0
io.open(README, 'w', encoding='utf-8').write(text.replace(current, wanted, 1))
print("index: written")
if "--print" in sys.argv:
print(render(topics, rs))
return 0
if current != wanted:
print("index: %s is stale. Regenerate it:\n"
" python3 00-META/checks/index.py --write" % README)
problems = []
if not topics:
problems.append("%s writes no reading order: no line of the form "
"'1. **Label** — `topic: name`. ...'" % README)
names = [t for t, _ in topics]
if len(set(names)) != len(names):
problems.append("%s names a topic twice in its reading order" % README)
for pattern, what in STORED:
if pattern.search(text):
problems.append("%s stores the list of records (%s). The list is generated when read "
"(ADR 0248): `python3 00-META/checks/index.py --print`" % (README, what))
known = set(names)
for r in rs if known else []: # with no reading order, every record would be named; one line says why
if r["topic"] not in known:
problems.append("%s: topic %r is not one of the reading order's: %s"
% (r["file"], r["topic"], ", ".join(names)))
for p in problems:
print("index: " + p)
if problems:
return 1
print("index: current")
print("index: %d topics, %d records, no stored list" % (len(topics), len(rs)))
return 0
+6 -5
View File
@@ -265,14 +265,15 @@ def check_supersession_symmetry(failures, records):
def check_topics(failures, records):
"""Every record names a topic the index knows.
"""Every record names a topic of the reading order.
The topic is what puts a record in the reading order, so a record without one — or with one
nobody defined — disappears from the index rather than appearing in the wrong place. That is
the quiet failure, so it is the one checked.
nobody defined — disappears from the generated list rather than appearing in the wrong place.
That is the quiet failure, so it is the one checked. The topics are the ones
`02-DECISIONS/README.md` writes down, read the way `index.py` reads them, so there is one list.
"""
known = {"the mesh", "the tiers", "what runs on it", "building it", "checking it",
"how we work"}
from index import reading_order
known = {topic for topic, _ in reading_order()}
for number, record in sorted(records.items()):
topic = record["front"].get("topic")
if not topic:
+17
View File
@@ -0,0 +1,17 @@
# Words allowed — documents `words.py` does not hold to the glossary's retired words
Read by [`words.py`](words.py) ([ADR 0244](../../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)).
One row per document. *Until* is the date by which it is reworded — past it, the row fails like the words
themselves — or `kept` for a graduated research effort, which records what was said and keeps its words
the way a decision record does. A row whose document no longer uses a retired word fails too, so this
list only shrinks.
| Document | Until | Why |
|---|---|---|
| `01-RESEARCH/034-the-mesh-in-domains/00-overview.md` | kept | the effort that studied these words; its findings quote and propose them as they stood before ADR 0244 |
| `01-RESEARCH/034-the-mesh-in-domains/01-the-concepts-in-use.md` | kept | as above: the inventory of the words in use |
| `01-RESEARCH/034-the-mesh-in-domains/02-the-domains.md` | kept | as above: the proposed domains, in the words of the day |
| `01-RESEARCH/034-the-mesh-in-domains/03-the-clashes.md` | kept | as above: every clash names the words that clashed |
| `01-RESEARCH/034-the-mesh-in-domains/04-how-the-glossary-is-checked.md` | kept | as above: the check's own examples |
| `01-RESEARCH/034-the-mesh-in-domains/05-a-glossary-by-domain.md` | kept | as above: the proposed glossary, with the entries of the day |
| `01-RESEARCH/042-the-mesh-keeps-its-own-issues/00-overview.md` | kept | the effort that settled the word: it records the operator's ask in the words it was made in |
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""One word per thing, checked (ADR 0244).
The glossary (00-META/glossary.md) is the authority on the mesh's words. It names the words it
retired on one fixed kind of line, so a program can read them:
*Not:* ~~control plane~~, ~~overlay~~ (hq)
A struck word with no scope is retired everywhere; `(hq)` retires it in this repository's prose only;
`(tools)` in the descriptions of the mesh's tools only. And it names code that still carries an old
name until that code is renamed:
*Identifier until renamed:* `mesh-host` — the repository, binary and service unit
An identifier is allowed inside a code span and nowhere else.
What is enforced:
retired no retired word of scope `hq` (or none), and no identifier, in running prose of a
checked document. Running prose is what is left once code blocks, code spans, block
quotes, text in quotation marks, struck-through text, link targets, HTML comments and
frontmatter are taken out: a quotation keeps the words it quotes, a link target is a file
name, and an identifier lives in a code span. A word's plural is the word: "alerts" fails
as "alert" does.
unique no head word (a bolded word opening a glossary entry) heads two entries, and no head
word is also struck through on a *Not:* line.
tools list with `--list tools` it prints the words retired in the tools' descriptions, which is the
copy the catalogue's own check keeps (mesh-catalog `retired-words`). With
MESH_CATALOG_DIR set to a checkout of the catalogue it also fails when that copy differs.
lines no daemon's line in a checked document (ADR 0272, its insight of 2026-10-09). The operator
types one command, `nox` (`mesh-cli`); the node-engine (`nox-mesh-host`) and the
controller (`mesh-controller`) are daemons, and a person never types their names — so a
document names a `nox` line, never `mesh-controller push` or `mesh-host hand-over`. The
lines are DAEMON_LINES below: each daemon's commands, and which of them are a line only
with an argument after them ("the mesh-controller module" is prose; `mesh-controller
module check …` is a line). A line is looked for in the document's own words — code
blocks and code spans included, since that is where a line to type is written — with
quotations, block quotes, link targets and comments taken out. `--list lines` prints the
list, which the catalogue's own check keeps a copy of (mesh-catalog `retired-lines`); with
MESH_CATALOG_DIR set it also fails when that copy differs. The node-engine's install-time
subcommands (`nox-mesh-host enrol`, `key`, `reconcile`, …) are not lines here: they are
typed at a machine no mesh reaches yet, and the engine's usage says so.
Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; a research
effort initiated, or an issue opened, on or after FROM (on or after LINES_FROM for the lines). Never
02-DECISIONS/: a decision record keeps the words it was written with.
A document the check fails on that cannot be reworded in the change that found it is named, with a date
and a reason, in words-allowed.md beside this file. An entry past its date fails like the word itself, and
an entry for a document that no longer needs it fails too. `kept` instead of a date is allowed only for a
graduated research effort, which is a record of what was said, like a decision record.
python3 00-META/checks/words.py
python3 00-META/checks/words.py --list tools
python3 00-META/checks/words.py --list lines
"""
import datetime
import os
import re
import sys
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
GLOSSARY = "00-META/glossary.md"
ALLOWED = "00-META/checks/words-allowed.md"
# The day the rule began (ADR 0244): research and issues from it on are held to it.
FROM = "2026-10-07"
ALWAYS = ("00-META/", "03-DESIGN/", "AGENTS.md", "README.md")
# The daemons' lines (ADR 0272, its insight of 2026-10-09), and the day that rule began. "alone" is a
# command that is a line by itself; "with an argument" one that is a line only when written as one.
LINES_FROM = "2026-10-09"
DAEMON_LINES = {
"mesh-controller": {
"alone": ("adopt assign broker cancel cleanup collect conditions converge doctor durations hand-act "
"hand-acts healers identity kill licence migrate operator pause pin plan prepare push rebuild "
"rehearse replay resume retire rollout rotate secret settings status token unassign unpin "
"upgrade").split(),
"with an argument": ("artifacts ask builds bus collection data delivery images mirrors module node "
"overlay plans queue seat seats take version").split(),
},
"mesh-host": {
"alone": "apply bundle enrol enroll hand-over help inventory key overlay owned profile reconcile run version".split(),
"with an argument": [],
},
"nox-mesh-host": {"alone": ["hand-over"], "with an argument": []},
}
NOT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Not:\*(.*)$", re.M)
STRUCK = re.compile(r"~~([^~]+)~~(?:\s*\((hq|tools)\))?")
IDENT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Identifier until renamed:\*(.*)$", re.M)
CODE_SPAN = re.compile(r"`([^`]+)`")
HEAD = re.compile(r"^\s*-\s+\*\*([^*]+)\*\*", re.M)
def rel(path):
return os.path.relpath(path, ROOT)
def read(path):
with open(os.path.join(ROOT, path), encoding="utf-8") as handle:
return handle.read()
def frontmatter_field(text, name):
if not text.startswith("---\n"):
return None
end = text.find("\n---", 4)
m = re.search(r"^%s:\s*(\S+)" % name, text[4:end], re.M)
return m.group(1) if m else None
def glossary():
"""The retired words with their scope, the identifiers, and the head words."""
text = read(GLOSSARY)
retired = []
for line in NOT_LINE.findall(text):
for word, scope in STRUCK.findall(line):
for one in word.split(" / "):
retired.append((one.strip(), scope or "all"))
identifiers = []
for line in IDENT_LINE.findall(text):
identifiers += [i.strip() for i in CODE_SPAN.findall(line)]
heads = []
for head in HEAD.findall(text):
heads += [h.strip() for h in head.split(" / ")]
return retired, identifiers, heads
def pattern(word):
"""A whole-word, case-insensitive match; a space in the word matches a space, a line break or a hyphen.
The plural is the word too: its last part may end in `s` or `es` ("alerts", "control planes"), since
a retired word does not come back by being counted. Anything else joined on is another word.
"""
parts = [re.escape(p) for p in word.split()]
return re.compile(r"(?i)(?<![\w-])" + r"[\s-]+".join(parts) + r"(?:e?s)?(?![\w-])")
def blank(match):
return re.sub(r"[^\n]", " ", match.group(0))
def prose(text, glossary_file=False):
"""The text with everything that is not running prose blanked, keeping offsets and line numbers."""
if text.startswith("---\n"):
end = text.find("\n---", 4)
if end != -1:
text = re.sub(r"[^\n]", " ", text[: end + 4]) + text[end + 4:]
steps = [
r"(?ms)^\s*```.*?^\s*```", # code blocks
r"(?s)<!--.*?-->", # comments
r"(?m)^\s*>.*$", # block quotes
r"`[^`\n]+`", # code spans
r"\]\([^)\s]*\)", # link targets
r"~~[^~\n]+~~", # struck through: a word named as retired
r"\"[^\"\n]*\"", # "quoted"
r"\u201c[^\u201d]*\u201d", # curly double quotes
r"\u2018[^\u2019\n]*\u2019", # curly single quotes
]
if glossary_file:
steps[:0] = [NOT_LINE.pattern, IDENT_LINE.pattern]
for step in steps:
text = re.sub(step, blank, text)
return text
def research_and_issues(since=FROM):
"""The research efforts and issue reports dated on or after `since`, file by file."""
out = []
for folder, key, first in (("01-RESEARCH", "initiated", "00-overview.md"), ("04-ISSUES", "opened", "00-report.md")):
base = os.path.join(ROOT, folder)
for effort in sorted(os.listdir(base)):
head = os.path.join(base, effort, first)
if not os.path.isfile(head):
continue
date = frontmatter_field(read(rel(head)), key)
if not date or date < since:
continue
for name in sorted(os.listdir(os.path.join(base, effort))):
if name.endswith(".md"):
out.append("%s/%s/%s" % (folder, effort, name))
return out
def checked_documents(since=FROM):
docs = []
for entry in ALWAYS:
path = os.path.join(ROOT, entry)
if os.path.isfile(path):
docs.append(entry)
continue
for base, dirs, files in os.walk(path):
dirs.sort()
for name in sorted(files):
if name.endswith(".md"):
docs.append(rel(os.path.join(base, name)))
return docs + research_and_issues(since)
def allowed():
"""words-allowed.md: one row per document — | `document` | until | why |.
`until` is a date, after which the allowance fails like the words themselves, or `kept` for a
document that records what was said and must keep its words: only a research effort that has
graduated may be kept, because once it has become a decision it is a record like one.
"""
out, problems = {}, []
if not os.path.isfile(os.path.join(ROOT, ALLOWED)):
return out, problems
today = datetime.date.today().isoformat()
for line in read(ALLOWED).splitlines():
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) != 3 or not cells[0].startswith("`"):
continue
doc, until, why = cells[0].strip("`"), cells[1], cells[2]
if not why:
problems.append("%s: the allowance for %s gives no reason" % (ALLOWED, doc))
if until == "kept":
overview = os.path.join(os.path.dirname(doc), "00-overview.md")
status = frontmatter_field(read(overview), "status") if os.path.isfile(os.path.join(ROOT, overview)) else None
if not doc.startswith("01-RESEARCH/") or status != "graduated":
problems.append("%s: %s is kept, but only a graduated research effort may be" % (ALLOWED, doc))
continue
elif not re.match(r"^\d{4}-\d{2}-\d{2}$", until):
problems.append("%s: the allowance for %s has neither a date nor `kept`" % (ALLOWED, doc))
continue
elif until < today:
problems.append("%s: the allowance for %s ran out on %s — reword it" % (ALLOWED, doc, until))
continue
out[doc] = until
return out, problems
def check_retired(retired, identifiers):
problems = []
allowance, problems_allowed = allowed()
problems += problems_allowed
words = [(w, pattern(w), "retired") for w, scope in retired if scope in ("all", "hq")]
words += [(i, pattern(i), "an identifier, allowed only in a code span") for i in identifiers]
used = set()
for doc in checked_documents():
text = prose(read(doc), glossary_file=(doc == GLOSSARY))
for word, rx, why in words:
for m in rx.finditer(text):
if doc in allowance:
used.add(doc)
continue
line = text.count("\n", 0, m.start()) + 1
problems.append("%s:%d: %r is %s (glossary)" % (doc, line, m.group(0).replace("\n", " "), why))
for doc in allowance:
if doc not in used:
problems.append("%s: %s no longer uses a retired word — remove its allowance" % (ALLOWED, doc))
return problems
def lines_list():
"""The daemons' lines as the catalogue's copy lists them: `<daemon> <command>`, ` …` when an argument must follow."""
out = []
for daemon, kinds in DAEMON_LINES.items():
out += ["%s %s" % (daemon, c) for c in kinds["alone"]]
out += ["%s %s …" % (daemon, c) for c in kinds["with an argument"]]
return sorted(out)
def line_pattern(daemon, command, with_argument):
"""The line whole: the daemon, white space, the command. One that is a line only with an argument is matched
as a line is written — at the start of a line or a code span, a prompt allowed, an argument after it."""
d, c = re.escape(daemon), re.escape(command)
if with_argument:
return re.compile(r"(?m)(?:^|`)[ \t]*(?:\$[ \t]*)?(" + d + r"\s+" + c + r"\s+[^\s`])")
return re.compile(r"(?<![\w-])" + d + r"\s+" + c + r"(?![\w-])")
def own_words(text):
"""The text with what is not the document's own words blanked: as prose(), but a code block and a code span
are kept, since a line to type is written in one."""
if text.startswith("---\n"):
end = text.find("\n---", 4)
if end != -1:
text = re.sub(r"[^\n]", " ", text[: end + 4]) + text[end + 4:]
for step in (
r"(?s)<!--.*?-->", # comments
r"(?m)^\s*>.*$", # block quotes
r"\]\([^)\s]*\)", # link targets
r"~~[^~\n]+~~", # struck through
r"\"[^\"\n]*\"", # "quoted"
r"\u201c[^\u201d]*\u201d", # curly double quotes
r"\u2018[^\u2019\n]*\u2019", # curly single quotes
):
text = re.sub(step, blank, text)
return text
def check_lines():
problems = []
patterns = []
for daemon, kinds in DAEMON_LINES.items():
patterns += [(daemon, c, line_pattern(daemon, c, False)) for c in kinds["alone"]]
patterns += [(daemon, c, line_pattern(daemon, c, True)) for c in kinds["with an argument"]]
for doc in checked_documents(LINES_FROM):
text = own_words(read(doc))
for daemon, command, rx in patterns:
for m in rx.finditer(text):
line = text.count("\n", 0, m.start()) + 1
problems.append("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (ADR 0272)"
% (doc, line, daemon, command))
return problems
def check_unique(retired, heads):
problems = []
seen = {}
for head in heads:
key = head.lower()
if key in seen:
problems.append("%s: %r heads two entries" % (GLOSSARY, head))
seen[key] = True
for word, _ in retired:
if word.lower() in seen:
problems.append("%s: %r is a head word and also retired" % (GLOSSARY, word))
return problems
def tools_list(retired):
return sorted({w for w, scope in retired if scope in ("all", "tools")}, key=str.lower)
def check_copy(retired):
catalogue = os.environ.get("MESH_CATALOG_DIR")
if not catalogue:
print("NOT COMPARED: MESH_CATALOG_DIR is not set, so the catalogue's copy of the list was not read")
return []
path = os.path.join(catalogue, "retired-words")
try:
with open(path, encoding="utf-8") as handle:
copy = [l.strip() for l in handle if l.strip() and not l.startswith("#")]
except OSError as err:
return ["the catalogue's copy of the retired words cannot be read: %s" % err]
want = tools_list(retired)
problems = []
if sorted(copy, key=str.lower) != want:
problems.append("the catalogue's retired-words differs from the glossary: it should list %s" % ", ".join(want))
path = os.path.join(catalogue, "retired-lines")
try:
with open(path, encoding="utf-8") as handle:
copy = [l.strip() for l in handle if l.strip() and not l.startswith("#")]
except OSError as err:
return problems + ["the catalogue's copy of the daemons' lines cannot be read: %s" % err]
if sorted(copy) != lines_list():
problems.append("the catalogue's retired-lines differs from this check's list: regenerate it with "
"`python3 00-META/checks/words.py --list lines`")
return problems
def main(argv):
retired, identifiers, heads = glossary()
if argv[1:2] == ["--list"]:
scope = argv[2] if len(argv) > 2 else "tools"
if scope == "lines":
words = lines_list()
else:
words = tools_list(retired) if scope == "tools" else sorted({w for w, s in retired if s in ("all", scope)})
print("\n".join(words))
return 0
if not retired:
print("words: the glossary names no retired word on a *Not:* line — the check would pass on anything")
return 1
problems = check_unique(retired, heads) + check_retired(retired, identifiers) + check_lines() + check_copy(retired)
for p in problems:
print(p)
if problems:
print("words: %d problem(s)" % len(problems))
return 1
print("words: %d retired words and %d identifiers, none in running prose; %d daemons' lines, none written; "
"%d head words, each once" % (len(retired), len(identifiers), len(lines_list()), len(heads)))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+1 -1
View File
@@ -11,7 +11,7 @@ Imagine the mesh works as intended. What is different?
An agent says what it wants — from a terminal, a phone, a message — and the mesh takes it
from there. It works out which nodes are involved, does the work, and returns a
result. Nobody opens a console, recalls which node holds what, or follows a runbook
result. Nobody opens a terminal, recalls which node holds what, or follows a runbook
written months ago.
The interface is intent. The mesh handles the rest.
+595 -79
View File
@@ -1,97 +1,613 @@
# Glossary — the words this repository uses, and the ones it stopped using
# Glossary — the words of the mesh, by domain, and the ones it stopped using
One name per thing. This page is the authority; where an older record says something else, that
record is being superseded, not this page. It exists because the terms kept drifting in
conversation — control plane / controller / master / hub for one thing, substrate / foundation for
another — and a mesh you cannot name precisely is a mesh two people describe differently.
record keeps its words and this page says how to read them. It exists because the words kept drifting
in conversation — "control plane", "controller" and "master" for one thing, "substrate" and
"foundation" for another — and a mesh you cannot name precisely is a mesh two people describe differently. The rule and
its check are [ADR 0244](../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md);
the domains are drawn in [to-be 49](../03-DESIGN/01-to-be/49-the-mesh-in-domains.md).
## The mesh and its machines
## How to read this page
- **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.
- **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
were last told; they simply cannot be told anything new.
- ~~master / slave~~, ~~hub / peer~~ — not used. The relationship is *controller and nodes*, and no
node is subordinate: a node applies declarations on its own and survives the control-node dying.
A **domain** is an area of the mesh that owns a set of concepts: inside it each concept has one word,
and the domain decides what that word means. Other domains use the word as it is defined here and never
change its meaning. Every word is defined once, in the domain that owns it; a section lists the words
it uses from other domains under *Uses*, with no second definition.
## What runs the mesh
Two fixed lines follow an entry where they apply, and `00-META/checks/words.py` reads them:
- **controller** — the component that decides what each node should be, holds the mesh's records,
and tells nodes over the broker. Replaces **"control plane"** (borrowed from networking's
control-plane/data-plane, and opaque here).
- **mesh-controller** — the module that runs the controller. It **claims** the `mesh-controller`
seat at mesh scope, which is what makes it singular. Replaces the module name **`mesh-control`**.
(The git repository has been renamed `mesh-control` -> `mesh-controller` on the forge; the module,
container and image it produces are `mesh-controller`.)
- **foundation** — the store and the broker, raised at genesis before any module system exists.
Replaces **"substrate"** (a biology metaphor that landed for no one). The foundation is not a
third thing beside the store and broker — it *is* those two, named together.
- **store** — the one postgres server. It holds the controller's own context databases
(`inventory`, `identity`, `licences` — a context owns its store, [ADR 0008](../02-DECISIONS/0008-a-context-owns-its-store.md))
and every module's own database. One server, many databases — never one shared "mesh database".
- *Not:* followed by struck-through words — the words this entry replaced. A struck word with no scope
is retired everywhere; *(hq)* retires it in this repository's prose only; *(tools)* in the
descriptions of the mesh's tools and seat verbs only. A retired word may be quoted, never used.
- *Identifier until renamed:* followed by a name in a code span — code that still carries an old name.
It may stand in a code span and nowhere else until the code is renamed, and then the line goes.
Nothing else on this page is struck through. A word that means different things in different domains
is listed under [Homonyms](#homonyms), with the qualified form each domain uses.
## Words every domain uses
- **mesh** — the whole: the nodes, the modules assigned to them, the controller that decides and the
records it keeps. One mesh per operator; a second mesh is a second everything.
- **domain** — an area of the mesh that owns a set of concepts and the one word for each, as described
in the section above ([ADR 0244](../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)).
ADR 0006 called seven of them the controller's *contexts*; they carry over as domains under the same
names, except *observability*, which is now **Health and repair**. Where a domain's records live in
the controller, the domain owns that store alone ([ADR 0008](../02-DECISIONS/0008-a-context-owns-its-store.md)).
*Context* in that sense is not used in new writing; the word stays ordinary English, so this is
checked by review, not by `words.py`.
- **machine** — any computer: hardware, a virtual machine, before, during or outside its membership of
the mesh. Prose about the computer itself — its disks, its kernel, the network it sits on, what was on
it before the mesh — says machine.
- **node** — a machine the mesh has adopted and owns. A machine becomes a node when it joins
([ADR 0004](../02-DECISIONS/0004-a-node-and-how-it-joins.md)), and from then on it runs the node-engine
and is either **adopted** (the mesh holds what it found there as found) or **converged** (the mesh made
it what it is) — [ADR 0100](../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md).
Prose about a member of the mesh says node. A node is just a machine that has joined; being one
implies nothing about what it runs.
- **module** — the unit the mesh assigns: one named thing, defined by its manifest, that a node runs —
a service, a container, files, packages, its own code — and the tools it serves
([ADR 0040](../02-DECISIONS/0040-what-a-module-is.md)). Everything configurable on a node is a module.
- **seat** — a named role at a scope (node / site / mesh), held by a module assignment, from a **closed
set** the mesh defines: a claim naming a seat outside the set is refused. A seat may **deliver a
provision**, and its holder is then the mesh's answer for it when several modules provide it
([ADR 0126](../02-DECISIONS/0126-a-module-declares-its-own-seats.md)). The set, with who holds each
seat, is the overview of what a mesh has ([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)).
A seat carries **verbs** — the tools every holder must serve. A verb added to a held seat is first
promised as optional, then served, then required ([ADR 0246](../02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md)).
- **operator** — the person who runs the mesh, and the one the mesh talks to.
*Not:* ~~master~~ (hq)
- **person** — any human, as against the mesh acting unattended: *a person's word* releases a held
delivery, *a person* deletes a consumer's data. The operator is one; the word says that a human, not
the mesh, acted.
## Module — what a module is and declares
**Purpose.** To say what one module is, in the one document every other domain reads.
**Recorded in** the catalogue, one manifest per module. **Upstream of** every other domain; its words
are a published vocabulary, fixed by the manifest's schema. **Decided by** ADR 0040, 0126, 0174, 0188,
0207, 0236, 0262.
**Uses:** module, seat, provision (Provisioning), data class (Data), health (Health and repair).
- **manifest** — the file a module is defined by (`module.json`): what it is, what it provides and
requires, the seats it claims, the resources it declares, its tools, its data and its health. Say
manifest in prose, not the file's name.
*Not:* ~~module definition~~ (hq)
- **resource** — one thing a manifest declares a node must have: a file, a directory, a service, a
container, a package, a bundle, an archive. A module **declares** resources; the verb *declares* is
this domain's and means what a manifest states.
- **claim** — a module taking a spot on a seat: `claims: [{name, scope}]` in a manifest. A mesh-scoped
exclusive claim is how the mesh says "there is one of me". A foundation seat is named after the role
it guards: the `mesh-controller`, `postgres` and `nats` 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),
[ADR 0116](../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md)).
- **setting** — a value a manifest declares and an assignment gives, one of the two ways a node varies
a module ([ADR 0174](../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
What a "flavor" once varied is a setting or a separate module.
*Not:* ~~flavor~~
- **preference** — a setting whose manifest gives it a default, because its value is true of the
software and only tuned per node: a font size, a width
([ADR 0262](../02-DECISIONS/0262-a-preference-has-a-default-in-its-manifest-and-the-operators-own-value-has-none.md)).
A setting with no default is the operator's own value: a domain, an identity.
*Not:* ~~tunable~~ (hq)
- **kept region** — a marked block in a managed file the mesh writes *into*, where the operator's own
lines survive every send and are given back when the module goes (ADR 0174). The other way a node
varies a module.
- **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; never an image. A **tools bundle** speaks MCP to
the tool runner. 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)).
- **tool** — one capability a module serves through the tool runner, addressed `<module>.<tool>`. A
seat's **verb** is the same thing carried by a seat rather than a module.
- **mesh-sdk** — the library a module's own code is written against, including the harness that serves a
tools bundle to the tool runner. The harness is part of it, not a library of its own.
*Not:* ~~tools-sdk~~
- **invokes** — the manifest word for the tools a module calls, `<module>.<tool>` each or `*` for every
one. A grant on the publish side and nothing else; a module that declares none calls nothing.
- **upgrade policy** — how a module's new builds reach its nodes: `roll` (one node first, judged, then
the rest), `together`, or `record` (register the build, send it nowhere)
([ADR 0236](../02-DECISIONS/0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md)).
The controller's verb `upgrade` shows it.
## Core — the mesh's own machinery
**Purpose.** To keep the controller, the bus, the store and every node's engine running and agreed, so
every other domain has something to run on. **Recorded by** the controller and the store.
**Upstream of** every domain but Module; the others use the bus and the store as they are.
**Decided by** ADR 0005, 0006, 0067, 0106, 0141, 0175, 0227, 0229.
**Uses:** node, module, seat.
- **controller** — the component that decides what each node should be, holds the mesh's records, and
tells nodes over the bus. The relationship is *controller and nodes*, and no node is subordinate: a
node applies its declaration on its own and survives the control-node dying.
*Not:* ~~control plane~~, ~~slave~~ (hq), ~~mesh-control~~ (hq)
- **mesh-controller** — the module that runs the controller. It claims the `mesh-controller` seat at
mesh scope, which is what makes it singular.
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one per
mesh. It is not a separate kind of node — it is a node that additionally runs the controller (and,
today, the foundation). Lose it and the other nodes keep running what they were last told; they simply
cannot be told anything new.
- **node-engine** — the program on every node that applies what the controller declares: it receives
the node's declaration, writes the files, runs the services and containers, and reports what it did.
It is the engine, not a module: it owns no file's content, and every file it writes belongs to the
module that declared it. *Agent* is avoided because that word means a coding agent here. A record
written before the rename keeps the old name. "The host" is retired in this repository's prose only:
in the tools' descriptions it is also an SSH server's or a container runtime's own word, so a
description that means the node-engine by it is found by review.
*Not:* ~~host agent~~, ~~the host~~ (hq), ~~node host~~
*Identifier until renamed:* `mesh-host` — the repository, its binary and its service unit; the rename is
[issue 354](../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)
- **tool runner** — the one program on each node that loads every assigned module's tools bundle and
serves every tool and every held seat's verb on the subjects the memberships issue; the node-engine
supervises it as a process beside the services it runs, never a container
([ADR 0175](../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
which called it "node tools"). Its mode on loopback is the mesh MCP server.
*Not:* ~~node tools~~, ~~tool runtime~~ (hq)
*Identifier until renamed:* `node-tools` — the module, its unit and its bus account; the rename is
[issue 354](../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)
- **foundation** — the store and the bus, raised at genesis before any module system exists. The
foundation is not a third thing beside the store and the bus — it *is* those two, named together.
*Not:* ~~substrate~~
- **genesis** — raising the foundation and the controller on an empty mesh, by the one installation
done by hand ([ADR 0067](../02-DECISIONS/0067-genesis-is-a-pivot.md)).
- **store** — the one postgres server. It holds the controller's own databases (`inventory`,
`identity`, `licences`, each owned by one domain, ADR 0008) and every module's own database. One server,
many databases — never one shared mesh database. Bare *store* means this and nothing else
(see [Homonyms](#homonyms)).
- **bus** — the mesh's own nervous system: NATS, one per mesh, carrying every link the mesh has —
control, declarations, builds, events, tool calls
([ADR 0106](../02-DECISIONS/0106-the-bus-is-nats.md)). A module reaches it by requiring
`mesh-bus` ([ADR 0128](../02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md)); one that
does not require it has no account on it. Held by the `mesh-broker` seat, which is named after
the *role* rather than the server, so the server can change without the seat doing so.
- **the deprecated broker** — the lavinmq module. It was the mesh's bus and is not any more. It
keeps running as an **ordinary provider** of the `amqp` provision, for modules that need a
message broker of their own the way something needs a database
([ADR 0127](../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md))) — no seat, not foundation,
never raised at genesis, and a mesh that never installs it is complete.
control, declarations, builds, events, tool calls ([ADR 0106](../02-DECISIONS/0106-the-bus-is-nats.md)).
A module reaches it by requiring `mesh-bus` ([ADR 0128](../02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md));
one that does not require it has no account on it. Held by the `mesh-broker` seat, which is named
after the role rather than the server, so the server can change without the seat doing so. The `nats`
module holds it.
- **the deprecated broker** — the lavinmq module. It was the mesh's bus and is not any more. It keeps
running as an **ordinary provider** of the `amqp` provision, for modules that need a message broker
of their own the way something needs a database
([ADR 0131](../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)) — it claims no
seat, is not foundation, is never raised at genesis, and a mesh that never installs it is complete.
Say *the deprecated broker*, not *the compatibility broker* (it serves the mesh's own modules, not only
the predecessor's) and not *the AMQP broker* (naming it after a protocol invites describing the bus by
contrast with it, which is backwards).
- **layer** — one of the four levels the mesh is built in, from the bottom: the node-engine, the
foundation, the controller, the surfaces. The records' `topic: the tiers` and `repos.md` say *tier* for
this; new prose says layer (see [Homonyms](#homonyms)).
- **lease** and **epoch** — which controller may send (the lease, held in the bus and renewed), and the
order of what it sent (the epoch, which a node reads before it accepts a declaration)
([ADR 0229](../02-DECISIONS/0229-the-cores-order-is-a-lease-the-store-remembers-and-an-epoch-a-machine-is-sent-once-it-reads-one.md)).
Say *the deprecated broker*, not "the compatibility broker" (it serves the mesh's own modules,
not only the predecessor's) and not "the AMQP broker" (naming it after a protocol invites
describing the bus by contrast with it, which is backwards: the bus is the mesh's nervous
system and this is a module).
## Placement — what runs on which node
## What the mesh stores and serves
**Purpose.** To decide, send and apply what each node runs, and to say why.
**Recorded by** the controller's `inventory` database. **Upstream of** Provisioning, Connectivity, Data
and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 0252, 0261.
**Uses:** node, machine, module, seat, setting (Module), manifest (Module).
- **package** — what code resolves when it is **compiled**: an npm/cargo/pypi dependency, by
**version**. Served by the **package-registry** (gitea). Only a builder talks to it.
- **artifact** — anything a build produces and the mesh delivers to a machine by **digest**: an
`image`, a mirrored `upstream` image, a `bundle` of the module's own code, an `archive`. Served by
the **artifact-store**, an OCI registry that holds every kind as content-addressed blobs
([ADR 0156](../02-DECISIONS/0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)).
Every node pulls from it. An image is one kind of artifact, and a module is not an image.
- These are two protocols, not one store being weak — see [ADR 0075](../02-DECISIONS/0075-two-stores-and-which-provides-what.md).
- **assignment** — a module put on a node, with the settings that node gives it.
- **pending assignment** — an assignment of a module whose build is still running, kept, and made by the
controller when that build registers the module, or ended with why when it does not
([ADR 0261](../02-DECISIONS/0261-an-assignment-made-while-its-modules-build-runs-is-kept-pending-and-the-controller-makes-it.md)).
- **scope** — where a seat or a claim holds: `node`, `site` or `mesh`. `node` is also the scope's
value in a manifest.
- **capacity** — how many holders a seat takes. A capacity-1 seat is exclusive. A higher-capacity seat
is a **bench**: several holders coexist. A **replicated** bench has one holder per node, each on record
and each answering the same, like `mesh-dns-resolver`
([ADR 0223](../02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md)). A
**kinded** bench has holders that are different modules, each claiming one **kind**, and a verb's
subject carries the kind; `channel` and `intake` are the only ones (ADR 0234).
- **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)).
The module that holds a seat is its **holder**.
- **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.
- **declaration** — what the controller sends one node: every resource of every module assigned there,
composed. The noun is this domain's; what a manifest says is that it *declares* (Module), and a manifest
is never called a declaration. The controller's verb `plan` previews a node's declaration.
- **send** — the controller giving a node its declaration. The controller's verb `push` asks for a
send (of one node, or of every node); prose says send. A git push and a phone's push notification are
other things (see [Homonyms](#homonyms)).
- **apply** — the node-engine making its node match its declaration; **reconcile** is doing so again
until nothing differs.
- **operator account** — the login name of the person who works on a node, stated on the node record;
empty for a node 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 a name a manifest carries.
- **agent account** — the login every coding agent on a node runs as, when the node names one of its own;
stated on the node record at the controller's terminal, never by a verb or a setting. Empty means agents
run as the operator account. It cannot become root without a person, and the node-engine judges that on
every look ([ADR 0266](../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md)).
- **account group** — a group of the machine's group database that a module puts an account in, by
declaring the account with that group and nothing else of it. Several modules may each add one to the
same account; the mesh takes back only an account group it added
([ADR 0252](../02-DECISIONS/0252-a-module-puts-an-account-in-a-group-and-the-mesh-says-when-a-new-login-is-needed.md)).
Never *membership*, which is the bus's word.
## How modules relate to the mesh
## Provisioning — one module serving another
**Purpose.** To resolve which module serves a provision for which consumer, and to wire the two.
**Recorded by** the controller. **Upstream of** Data; downstream of Module, Placement and Identity.
**Decided by** ADR 0027, 0084, 0138, 0225, 0230, 0263.
**Uses:** module, seat, credential (Identity and access).
- **seat** — a named role at a scope (node / site / mesh), held by a module assignment, from a
**closed set** the mesh defines: a claim naming a seat outside the set is refused. A seat may
**deliver a provision**, and its holder is then the mesh's answer for it when several modules
provide it ([ADR 0126](../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md))).
The set, with who holds each seat, is the overview of what a mesh has
([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)). A seat has a **capacity**: a
capacity-1 seat is exclusive (one holder); a higher-capacity seat is a **bench** (several holders
coexist).
- **claim** — a module taking a spot on a seat. `claims: [{name, scope}]` in a manifest. A
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)).
- **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
what makes a module *the* provider of it.
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 what makes
a module *the* provider of it.
- **provider** and **consumer** — the module that provides a provision, and the module that requires
it. Which of several providers serves a consumer is resolved
([ADR 0084](../02-DECISIONS/0084-which-provider-serves-a-consumer.md)).
- **pin** — a person's choice of provider for a consumer, which resolution then respects.
- **endpoint** — where a consumer reaches its provider; an assignment binds it and says how far it
reaches ([ADR 0138](../02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)).
- **retire** — a provider stops serving a consumer the mesh no longer asks for; what it kept is deleted
only by a person ([ADR 0230](../02-DECISIONS/0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md)).
- **share** — a folder one node offers to the mesh's other nodes over the private network: the provision
`nfs-share`, provided by the holder of the node seat `node-nfs-server`, one share per folder
([ADR 0263](../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md)).
- **mount** — a share, or an occasional source, made visible at a path on one node by the holder of the
node seat `node-mounts`, as a mount unit and an automount unit, mounted on access (ADR 0263).
- **occasional source** — a share on a device that is not a node and comes and goes, such as a USB disk;
its absence is a state, *not present*, never a fault (ADR 0263).
## The surfaces
## Identity and access — who may do what
- **console** — the module (`mesh-console`) that puts the mesh's tools in front of whoever is on a
machine: an MCP endpoint on the machine's loopback for an agent, the same endpoint for a person. It
is assigned like any module, holds a credential the mesh minted, and calls tools under a grant its
manifest declares (`invokes`). Loopback is the authority boundary: whoever is on the machine owns the
mesh there ([ADR 0152](../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
Not "the tool bridge", "the brain" or "the MCP server" — those name the predecessor's program or a
protocol, and the console is a module.
- **invokes** — the manifest word for the tools a module calls, `<module>.<tool>` each or `*` for
every one. A grant on the publish side and nothing else; a module that declares none calls nothing.
**Purpose.** To issue, hold, rotate and check the credentials and grants by which modules, nodes, agents
and the operator act. **Recorded by** the controller's `identity` and `licences` databases and the vault.
**Upstream of** Provisioning, Change and delivery, and Operator and conversation.
**Decided by** ADR 0085, 0113, 0183, 0225, 0234.
**Uses:** module, node, operator, consumer (Provisioning).
- **credential** — anything that proves an identity: a password, a key, a token. A **pair credential**
is the two ends of one credential, the consumer's and the provider's.
- **secret** — a credential or other value the vault keeps sealed and the node-engine unseals into a
file at apply ([ADR 0085](../02-DECISIONS/0085-a-secret-is-a-provision.md)).
- **vault** — the module that owns every secret (`mesh-vault`,
[24 — The secrets vault](../03-DESIGN/01-to-be/24-the-secrets-vault.md)).
- **grant** — what an identity may call or reach, bounded by the provisions it requires
([ADR 0225](../02-DECISIONS/0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md)).
- **bus account** and **membership** — a module's or a node's account on the bus, and the subjects it
is issued. The bus server calls its accounts *users*; the mesh says bus account.
- **rotate** — replacing a credential without a consumer holding one the provider does not know.
- **licence** — a model-access account the licence manager holds and **binds** to a consumer, the
coding agent on a node ([ADR 0183](../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)).
Taking a consumer off a licence is **unbinding** it; the licence manager's verb for it is still
`release`, an identifier to rename.
- **proof** — what an authorising answer carries: a verified sender, a one-time code, a security key's
touch (ADR 0234). How much proof a kind of ask needs is its **assurance level**.
- **controller's terminal** — a command line the controller runs as typed by its operator, not asked through a
verb, so a change that is the terminal's alone is taken (issue 339, issue 340). In the controller's code it is a
line run without `MESH_VERB`. The operator reaches it with `mesh-cli` from their own account on a node where no
agent can run as that account: the control-node, and a node whose agents run under an account of their own
([ADR 0272](../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md)).
## Change and delivery — a commit on its way to the nodes
**Purpose.** To take one commit from its pull request to every node that should run it, and to put it
back when it fails. **Recorded by** the `mesh-delivery` module (deliveries) and the controller (the
planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
**Decided by** ADR 0162, 0218, 0236, 0237, 0238, 0239, 0250, 0267, 0270, 0276.
**Uses:** module, node, declaration and send (Placement), health (Health and repair), person.
- **merge check** — the two statuses a pull request carries before it may merge: the **merge gate**
(`mesh/merge-gate`, the build seat's composed check of the modules the change touches) and the
**repository check** (`mesh/repo-check`, the repository's own `merge-check.sh`)
([ADR 0238](../02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md)).
- **build seat** — the seat whose holder builds modules, `node-build-agent`; its holder on a node is the
**builder**. Any node may hold the seat, so there is no build node by nature
([ADR 0237](../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md)).
*Not:* ~~build machine~~
- **build queue** — the controller's queue of builds to run; one entry in it is a **build request**.
The queue's verbs still say *ask* for an entry; the conversation's ask is another thing
(see [Homonyms](#homonyms)).
- **package** — what code resolves when it is **compiled**: an npm/cargo/pypi dependency, by **version**.
Served by the **package-registry** (gitea). Only a builder talks to it.
- **artifact** — anything a build produces and the mesh delivers to a node by **digest**: an `image`, a
mirrored `upstream` image, a `bundle` of the module's own code, an `archive`. Served by the
**artifact store**, an OCI registry that holds every kind as content-addressed blobs
([ADR 0156](../02-DECISIONS/0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)).
An image is one kind of artifact, and a module is not an image. A package and an artifact are two
protocols, not one store being weak ([ADR 0075](../02-DECISIONS/0075-two-stores-and-which-provides-what.md)).
- **build source** — the files one artifact is built from, in one or more repositories: for a Go program
its import closure (the directories of its packages, its Go module files) and its recipe; for any other
artifact the paths its manifest names, by default its module's own directory. Each artifact of a build
has its own, and a change rebuilds the artifacts whose build source it touches
([ADR 0267](../02-DECISIONS/0267-an-artifact-is-rebuilt-only-when-its-build-source-changes-and-a-module-moves-only-along-build-dependencies-on-artifacts.md)).
Older records (ADR 0015, ADR 0111) and designs say *build source* for the repository a module
is built from; this entry narrows it to the files within, and that use reads as *the repository holding
the build source*. Sharing a repository is not a dependency, and no build source is a whole repository
holding another module's files.
- **catalogue** — what the mesh holds of every module, at which commit; and the repository the
modules live in. `mesh-catalog` and `catalog_modules` are identifiers and keep their spelling.
*Not:* ~~catalog~~ (hq)
- **delivery** — one commit in one repository on its way to the nodes, from its pull request's head
being announced to one of its final states: one pull request, one status, one note
([ADR 0239](../02-DECISIONS/0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md)).
From its merge on it is delivered by the walk of the batch that answers it, and ends with that walk
([ADR 0276](../02-DECISIONS/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md)).
Its states are one table (`proposed`, `checked`, `ready` or `rejected`, `published`, `delivering`,
`held`, and the final six: `delivered`, `failed`, `superseded`, `stopped`, `retired` and `covered`;
[ADR 0250](../02-DECISIONS/0250-a-merge-heard-only-from-the-buss-history-is-retired-and-owed-work-the-forge-cannot-do-ends.md),
[ADR 0270](../02-DECISIONS/0270-a-held-delivery-that-moves-nothing-or-that-a-later-delivered-merge-contains-ends-by-itself-and-a-hold-whose-reason-is-gone-lifts.md)). Not *change* (a diff). *Pipeline* is the predecessor's build-and-deploy
mechanism, which the as-is designs describe; it is never a word for a delivery.
*Not:* ~~deployment~~ (hq)
- **covered** — the end of a delivery that a later merge containing it delivered: every module it moves was
sent to every machine running it by the walk of a later merge into the same trunk, delivered on its own
passing check, whose merge commit the forge says contains this one. Not *superseded*, which is a delivery
whose walk a newer delivery took over before it ended; nor *delivered*, which its own walk or an empty plan
ends in ([ADR 0270](../02-DECISIONS/0270-a-held-delivery-that-moves-nothing-or-that-a-later-delivered-merge-contains-ends-by-itself-and-a-hold-whose-reason-is-gone-lifts.md)).
- **delivery group** — two or more deliveries sharing a pull request head branch name across the mesh's
repositories, delivered as one unit in an order declared (`after:`) or inferred by the planner. One
level: a group holds deliveries, never groups. Its state is derived from its members, never set.
- **delivery plan** — what a delivery does to the mesh: its **build plan** (the modules it moves and
their dependents, in tiers), its **deploy plan** (per node, what it receives and what waits for a
person) and its verdict (the composed nodes, the replays). Computed by the controller's planner from a
diffset; the controller's verb `delivery-plan` shows it. *Deploy* is used in this one place.
*Not:* ~~change plan~~, ~~release plan~~
- **merge window** — the rolling wait in which the controller collects merges before it plans them: each
merge restarts it, and it closes when none arrived for its length (90 seconds by default) or at the latest
10 minutes after its first merge ([ADR 0276](../02-DECISIONS/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md)).
- **batch** — the merges one merge window collected, planned once as one walk. For a repository merged more
than once in it, the walk takes the latest commit, and the earlier merges are answered by it. Not *group*,
which is a delivery group.
- **mesh-delivery** — the module that owns deliveries and delivery groups, holding the mesh-scoped seat
of the same name. It records and decides; the controller sends, judges and rolls back when it is asked.
- **walk** — the controller's sending of one batch's builds across nodes, at most one trunk commit per
repository, tier by tier, one node first and judged at the first-node gate, then the rest (ADR 0236,
[ADR 0276](../02-DECISIONS/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md)). One walk is open at a time; it names every merge it answers. A primitive the delivering stage
asks for, not an object anyone manages. A **tier** is one step of a walk: a module, then the modules
built against it. The controller's verb `plans` lists the walks. Walking is not a noun of its own:
a module *rolls out* by its `roll` policy.
*Not:* ~~rollout~~ (hq)
- **first-node gate** — the judgement on a delivery's first node: healthy three times, no witness put it
back, no condition raised since the send (ADR 0236 §2). Not *the gate* bare (see [Homonyms](#homonyms)).
- **release** — a person's word that a held delivery goes on; the verb `mesh-delivery.release`. Used in
this sense only.
- **rollback** — putting a node back on the build it ran before, by something other than the build being
judged (a witness, ADR 0236).
- **the lab**, **scenario**, **replay** — a real mesh raised to run a change against before it reaches
nodes; what a lab run declares; and a recorded failure run again to prove a fix (ADR 0016, ADR 0237).
## Health and repair — what is wrong, and putting it right
**Purpose.** To notice what is wrong with the mesh, say it once, repair what may be repaired unattended,
and record what was done by hand. **Recorded by** the controller's condition store.
**Upstream of** Change and delivery (the first-node gate) and Operator and conversation (a condition
becomes a message). **Decided by** ADR 0227, 0231, 0240, 0252.
**Uses:** node, module, node-engine (Core).
- **health** — a module's or a core component's statement of how it is alive and ready, which the
node-engine judges ([ADR 0240](../02-DECISIONS/0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md)).
- **relogin needed** — an account's health when the group database lists it in an account group and its
running session, started before, does not have that group yet. Said by the node-engine as the module's
resource of kind `account`; it clears at the first look after a new login (ADR 0252).
- **probe** — one look at one thing's health, run by whoever owns the verdict; what a probe returns is a
**finding**, before it becomes a condition.
- **signal** and **watchdog** — something that must keep happening (a heartbeat, a report after a send),
and the watcher that raises a condition when it stops (to-be 45 §3).
- **condition** — one open fact about something the mesh owns that is wrong, with a key and a severity;
raised and cleared only by observation
([ADR 0231](../02-DECISIONS/0231-a-healer-acts-on-what-observation-raised-and-only-observation-says-it-worked.md)).
What a wrapped dashboard raises is its own; the mesh's notion is a condition.
*Not:* ~~alert~~ (hq)
- **self-check** — the controller's own examination of the mesh, run on demand and on a schedule
(to-be 45 §4). Its verb is `doctor`, an identifier; prose says self-check.
*Not:* ~~doctor~~ (hq)
- **healer** — a registered response to one condition kind: its **repair** (the ordinary path again),
its **budget**, its back-off and its brake. A healer may not withdraw, delete or recreate data.
- **drill** — something broken on purpose to see the mesh raise and clear its condition; a drill is
never counted as a repair.
- **rehearsal** — a question the operator starts at the controller's terminal (`rehearse`) and answers on a
channel, to see an answer travel the whole way: its approval performs nothing and is recorded as the
operator's decision ([ADR 0259](../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md)).
Not a drill: nothing is broken.
- **hand-act** — a repair a person made by hand, recorded so the mesh knows it happened.
- **witness** — what rolls a core component back when its new build does not become healthy: never the
component itself (to-be 45 §8).
## Data — what the mesh keeps, and getting it back
**Purpose.** To know every item of data a module holds, how precious it is, and to keep it recoverable.
**Recorded by** the manifests' `data` declarations and every node's `node-backup` seat.
**Upstream of** Change and delivery and Health and repair. **Decided by** ADR 0232, 0233, 0235.
**Uses:** module, node, provider and consumer (Provisioning), person.
- **data class** — how precious an item of data is, ranked by the operator: `irreplaceable`,
`valuable`, `rebuildable`, `cache`; and `none` for a provision that keeps nothing of anybody's
([ADR 0233](../02-DECISIONS/0233-a-module-declares-the-data-it-holds-and-the-mesh-protects-and-watches-it-from-that.md)).
- **backup** — a copy kept elsewhere by the node's `node-backup` holder; **restore point** — one backup
at one moment; **restore** — bringing it back beside the live data, never over it.
- **stream snapshot** — the bus's own backup of each stream, taken by the module holding `mesh-broker`
([ADR 0235](../02-DECISIONS/0235-the-bus-is-backed-up-by-its-own-snapshot-of-each-stream.md)).
- **sticky binding** — a consumer's tie to the data a provider keeps for it, which moves only by a
person ([ADR 0232](../02-DECISIONS/0232-a-binding-to-a-consumers-data-moves-only-by-a-person.md)).
## Connectivity — how nodes and modules reach one another
**Purpose.** To make every node and module reachable by name where it should be, and unreachable where
it should not. **Recorded by** the controller. **Upstream of** Provisioning and Health and repair.
**Decided by** ADR 0007, 0117, 0138, 0223, 0226, 0247.
**Uses:** node, machine, assignment and endpoint.
- **private network** — the mesh's own encrypted network between its nodes, on which every node has an
address and a mesh name ([ADR 0226](../02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md)).
The network the machine sits on without it is the **underlay**.
*Not:* ~~overlay~~ (hq)
- **anchor** and **hub** — the roles a node plays for the private network: the anchor is reachable from
outside and every node reaches it; a hub relays for nodes that cannot reach each other directly.
- **resolver** — a seat holder that answers the mesh's names; a node lists only the mesh's resolvers
(ADR 0223), or, where it holds one, its own resolver, which asks them. **uplink** — a node's
connection to the outside network, a seat ([ADR 0117](../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)). **hostname** — a node's own name,
a seat.
- **split DNS** — resolving names by domain on one machine: a VPN's domains through the VPN's servers
over its link, every other name through the mesh's resolvers. The provision `split-dns`, provided by
the holder of the node seat `node-resolver`, the machine's **own resolver**, which exists only where
something requires it ([ADR 0247](../02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md)).
- **proxy** and **public name** — the module that answers a public name and forwards it to an
endpoint on the private network.
- **packet filter** — what the mesh enforces on a node about which packets pass, the
`node-packet-filter` seat and its verb `rules`. A **found firewall** is a program the mesh found on a
machine and keeps retired or in force; *firewall* says that, and a wrapped program's firewall is its own.
- **intrusion prevention** and **ban** — refusing a source that misbehaved, and the refusal itself.
- **reach** — how far an assignment's endpoint may be reached from: the node, the private network, or
the outside (ADR 0138).
## Operator and conversation — the person the mesh works for
**Purpose.** To let the mesh and its operator talk: tell, ask, answer, and act only on an answer it can
trust. **Recorded by** the router (asks, warrants and the operator's identities).
**Downstream of** Health and repair, Change and delivery, and Identity and access. An authorising
answer acts in whichever domain asked, through that domain's own verb; the conversation owns the
asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272.
**Uses:** operator, person, proof (Identity and access), release (Change and delivery).
- **mesh MCP server** — the endpoint on a node's loopback through which every agent and person on that
node reaches the mesh: the MCP server named `mesh`, with its five tools `mesh_overview`,
`mesh_machine`, `mesh_search`, `mesh_describe` and `mesh_call`. It is the tool runner's loopback mode,
not a module of its own. "Console" suggested a terminal or a shell, and the module `mesh-console` it
once named no longer exists; "the tool bridge" and "the brain" named the predecessor's program.
*Not:* ~~console~~ (hq), ~~mesh-console~~, ~~tool bridge~~
- **mesh-cli** — the module on every node that places the operator's command, and the command itself:
`/usr/local/bin/mesh-cli <command line>` asks the controller to run one of its command lines. It asks the
node-engine on its own machine, which says to the controller which account is asking; the controller runs the
line as the **controller's terminal** or as an ordinary call
([ADR 0272](../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md)).
- **nox** — `mesh-cli`'s alias, in interactive zsh only. A script, `sudo` and a document meant to work everywhere
say `mesh-cli`. The operator also calls the mesh itself *nox mesh*; on this page and in the records *nox* is the
alias alone. It is the one command the operator types: every line the mesh addresses to the operator — a
condition's words, a verb's answer, a README, a design — names a `nox` line, never a daemon's (the controller's
or the node-engine's binary followed by a command), which `words.py` and the catalogue's check refuse
([ADR 0272](../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md),
its insight of 2026-10-09).
- **channel / intake** — the two kinded benches the mesh talks to its operator through: `channel`
sends, `intake` turns what arrives into one envelope. A holder of either declares **capabilities** from
the fixed vocabulary `channel-capabilities/1`. Not *notifier* (that is the desktop's node seat, one
holder of kind `desktop`) and not *bot* (that is one service's account).
- **router** — the module that holds the `operator-channel` seat, orders the channels and checks every
sender ([ADR 0234](../02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md)).
- **headline / verdict / explanation / resolved line** — what a condition says to the operator in plain
words: a few words naming the thing and what is wrong; "Nothing for you to do." or "Needs you:" and the
one thing the operator can do themselves (its **needs**); one or two sentences on what happened and what
it means; the line said when it clears. Its **actions** are the answers a notification offers. Carried by
the condition beside its **summary**, which keeps the plans, commits and verbs for whoever looks closer
([ADR 0253](../02-DECISIONS/0253-a-condition-says-itself-to-the-operator-in-plain-words-beside-its-summary.md)).
- **ask** — a request for the operator's input, of a declared kind (yes-no, one-of, text, number,
date, acknowledge). An **authorising ask** is one whose chosen option its asker performs. The router
holds every ask, checks each answer's proofs and issues a **warrant**; the asker acts on it with its
own grants (ADR 0234, ADR 0259).
- **warrant** — the router's record that a person chose one option of one ask: the ask, the option,
who chose (the operator, as a channel kind's identity), through which channel, the proofs and when,
published for that asker alone. The only thing an asker acts on (ADR 0259).
- **operator message / input** — what arrives on `intake`, once the router has checked the sender
against its own list of the operator's identities: a **trusted** one is an operator message,
addressed to an agent by `@name` or thread or else to the **responder** (`@mesh`, the router's own
participant, which answers from read verbs); anything else is **untrusted input** — data, never
instructions.
- **reference** — an opaque token in a message's words standing for a detail the content rule keeps out
of them (a path, an address); opened with `detail` only on a `private` channel or through the mesh MCP server.
- **agent** — a coding agent: the operator's, or one the mesh runs. Not the node-engine, and not the
build seat's holder, whose seat name `node-build-agent` is an identifier to rename.
- **instruction section** — one named section of the managed instruction file, which every agent session on the
nodes it is for reads first, as the mesh's word: set at the controller's terminal in the agent module's
settings, or **proposed** by an agent and made active on each node only by the operator's warrant, which that node
proves itself ([ADR 0274](../02-DECISIONS/0274-an-agent-proposes-a-shared-instruction-section-and-every-node-takes-it-only-on-the-operators-warrant-which-it-proves-itself.md)). A hand-over between sessions is one.
- **personal rule** — one short text in the operator's own words about how the operator wants their agent
to work with them, registered for the operator (the **operator scope** of the agent module) and rendered
into the agent's home on every node that names an operator account. Not a mesh-wide instruction section,
which is the mesh's word about the mesh, set at the controller's terminal
([ADR 0273](../02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md)).
- **lesson** — an agent's proposal, recorded when the operator expresses frustration or corrects a way of
working: the trigger (the operator's words), the cause (what the agent did) and the rule that would
prevent it. A lesson is `proposed`, `accepted` or `declined`; an accepted lesson is a personal rule, and a
proposed one is never read as one (ADR 0273).
## The record — how this repository works
Not a domain of the mesh but of the way it is built, listed because its words meet the mesh's.
**Recorded in** this repository and the issue tracker. **Decided by** ADR 0019, 0080, 0244, 0275.
- **research effort**, **graduation**, **hand-off**, **playbook** — an investigation in `01-RESEARCH/`;
its closing into a decision and a design; a design given to a code repository; a documented workflow
in `00-META/process/`.
- **decision record** — one numbered file in `02-DECISIONS/`, never rewritten: **superseded** by a later
record, or corrected by a marked **progressive insight**. Always qualified in this repository: bare
*record* has other meanings (see [Homonyms](#homonyms)).
- **design** — a document in `03-DESIGN/`: **as-is** (what runs) or **to-be** (what is being built).
- **issue** — a question about the mesh that someone must answer, numbered and tracked by the issue tracker;
one at the level of design or governance also has a report in `04-ISSUES/` under the same number
([ADR 0275](../02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md)).
An **incident** is a past event that taught a rule.
- **issue tracker** — the role that holds the mesh's issues and hands out their numbers: the seat `issue-tracker`,
held by the module `mesh-issues` ([53 — The issue tracker](../03-DESIGN/01-to-be/53-the-issue-tracker.md)).
Not hits's *item*; bare *ticket* is a homonym (see [Homonyms](#homonyms)).
*Not:* ~~ticketing system~~ (hq), ~~ticket system~~ (hq)
- **hq check** — one of `00-META/checks/`, run by `merge-check.sh` as this repository's check.
## Homonyms
A word below means different things in different domains. In a governing document it is never bare;
it is always the qualified form. **Checked by review, not by `words.py`:** a word list cannot tell one
sense from another, so the reviewer looks for the bare word in the diff. Where a homonym is settled by
renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
| Word | Sense | Say | Domain |
|---|---|---|---|
| plan | what the controller would send one node | that node's **declaration** (verb `plan`) | Placement |
| | what a delivery would do to the mesh | **delivery plan** | Change and delivery |
| | the sending of one batch of commits across nodes | **walk** (verb `plans`) | Change and delivery |
| | a step a person starts, like the bus's | **planned step** | Core |
| membership | the subjects a bus account is issued | **membership** | Provisioning |
| | an account in a group of the machine | **account group** | Placement |
| push | the controller giving nodes their declarations | **send** (verb `push`) | Placement |
| | a git push | **git push** | The record |
| | a phone notification | **push notification** | Operator and conversation |
| release | a person letting a held delivery go on | **release** | Change and delivery |
| | taking a consumer off a licence | **unbind** (verb `release`, to rename) | Identity and access |
| gate | the judgement on a delivery's first node | **first-node gate** | Change and delivery |
| | the pull request status | **merge gate** | Change and delivery |
| | a failed step stopping its module's later steps (ADR 0136) | *a failed step holds its module* | Placement |
| tier | a level of the mesh | **layer** | Core |
| | a step of a walk | **tier** | Change and delivery |
| | how much proof an ask needs | **assurance level** | Identity and access |
| ask | a request for the operator's input | **ask** | Operator and conversation |
| | an entry in the build queue | **build request** | Change and delivery |
| store | the one database server | **store** | Core |
| | the OCI registry | **artifact store** | Change and delivery |
| | a node's own copy of its last declaration | **last declaration** | Placement |
| record | a numbered decision | **decision record** | The record |
| | the knowledge base agents search first | **the record** (the `records` module) | Operator and conversation |
| | the upgrade policy that sends nowhere | `record` | Module |
| check | a pull request's two statuses | **merge check**, **merge gate**, **repository check** | Change and delivery |
| | a delivery group's verdict | **composed check** | Change and delivery |
| | the controller's own examination | **self-check** | Health and repair |
| | a file in `00-META/checks/` | **hq check** | The record |
| agent | a coding agent | **agent** | Operator and conversation |
| | the build seat's holder | **builder** | Change and delivery |
| the user | an account of a wrapped program, or of the bus server | **its account**, **bus account** | Identity and access |
| | a human | **operator** or **person** | words every domain uses |
| firewall | what the mesh enforces | **packet filter** | Connectivity |
| | what was found on a machine | **found firewall** | Connectivity |
| ticket | an answer's one-time token (ADR 0258) | **answer ticket** | Operator and conversation |
| | a question someone must answer | **issue** | The record |
| deploy | the per-node part of a delivery plan | **deploy plan** | Change and delivery |
| | a wrapped program's own deploy | its own word | — |
## How this page is kept
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.
A new word for an existing thing lands here first, in the domain that owns it, in the same change that
introduces it in code. A word moves to another domain only with a decision record. A retired word goes
on a *Not:* line and nowhere else on this page, and `words.py` then fails on it in running prose. A
record under `02-DECISIONS/` keeps whatever word it was written with — those are immutable — so a word
retired here may still appear there, and the *Not:* lines are how to read it.
**How it is checked** ([ADR 0244](../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)):
`python3 00-META/checks/words.py`, run by `merge-check.sh` on every pull request, fails on a retired
word or a bare identifier in running prose in `00-META/`, `03-DESIGN/`, `AGENTS.md`, `README.md`, and
research and issues dated from 2026-10-07 — code spans, quotations and link targets excepted — and on a
head word that heads two entries or is also retired. The words retired with no scope or with *(tools)*
are copied into the catalogue as `retired-words`, whose own repository check holds the descriptions of
the mesh's tools to them; a change here that retires such a word changes that copy in the same delivery.
Homonyms are checked by review.
+1 -1
View File
@@ -11,7 +11,7 @@ updated: 2026-08-22
An agent states an intent — in words, from wherever they already are — and the mesh
carries it out. It takes the request in, works out what it means, does the work across
whichever nodes it needs, and returns a result. No console to open, no runbook to follow,
whichever nodes it needs, and returns a result. No terminal to open, no runbook to follow,
no remembering which node holds which thing.
Not automation, which does what it was told to do in advance. Self-control: the mesh
+4 -3
View File
@@ -62,8 +62,8 @@ The flow above is a rule, and a rule states how it is checked:
[`00-META/checks/cycle.py`](../checks/cycle.py) refuses a to-be design that names no
decision, an `in-progress`/`implemented` design that names no owning code, an issue marked
`located`/`fixed` with no owner or `fixed`/`resolved` with no fix, and a `graduated`
research overview that does not say what it became. Run it with `records.py` and `index.py`
before any HQ merge. What the checks cannot see — that code work actually started from a
research overview that does not say what it became. Run it with `records.py`, `index.py` and
`words.py` before any HQ merge (`sh merge-check.sh` runs all four). What the checks cannot see — that code work actually started from a
handoff — is held by playbooks [04](04-build-handoff.md) and [07](07-feature-branches.md):
a feature branch exists because a design or an issue sent it.
@@ -74,4 +74,5 @@ YAML frontmatter (schemas in the section READMEs and playbooks). There are **no
files** and no decision ledger. **Every decision is a record in
[`02-DECISIONS`](../../02-DECISIONS/)** — if it is worth recording it is worth a record, and if
it is not worth a record it is not recorded. Cross-cutting views, the decision index included,
are generated on demand by the `hq-status` skill and never written to disk.
are generated on demand by the `hq-status` skill and never written to disk
([ADR 0248](../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)).
+7
View File
@@ -13,6 +13,13 @@
2. **Record the decision.** Write a record in [`02-DECISIONS/`](../../02-DECISIONS/) taking the next free
number. Format and rules are in [`02-DECISIONS/README.md`](../../02-DECISIONS/README.md). State evidence,
not assertion, and record the options that were rejected — that is the half worth keeping.
**A record that puts work off** — a rename, a check, a case it does not handle — names the issue that
tracks it and the owner who will do it, in the sentence that defers it ("tracked by issue N"), or the
deferral is not accepted:
a deferral nothing tracks reads as done
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md),
ADR 0244's code renames). [`cycle.py`](../checks/cycle.py) holds every accepted record dated from
2026-10-09, and an older one corrected under the rule, to it.
3. **Write the design.** Create the document under `03-DESIGN/01-to-be/` with frontmatter:
```yaml
+46 -13
View File
@@ -19,14 +19,36 @@ The knowledge base already holds the operational record and is indexed on sympto
folder is not a second copy of it. An issue here is a question HQ must **answer**, not an
incident someone must **clear**.
## The issue tracker holds the number
Every issue is opened in the **issue tracker**, the module `mesh-issues` holding the seat `issue-tracker` on the
control-node ([ADR 0275](../../02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md),
[to-be 53](../../03-DESIGN/01-to-be/53-the-issue-tracker.md)). Its number comes from one counter advanced by
compare-and-set, so two authors opening at the same moment never get the same one. **A number is never taken by
reading `main` or the open pull requests** — that is how numbers collided (issue 155: two records sharing a number
reached `main` with every check passing). A session that cannot reach the tracker takes no number and says so.
An issue takes one of two shapes:
- **With a folder here** — a design or governance issue, whose narrative belongs in this public record and is
reviewed in a pull request. The tracker holds its number, title, notes and links; its status stays in the folder's
frontmatter until slice 3 of to-be 53 has imported the folders and been checked, and until then the tracker's
`move` refuses it, saying its status lives in its folder.
- **Wholly in the tracker** — every other issue. Its status, owner, notes and links live in the tracker from the
start, and nothing is written here.
No status is ever kept in two places for one issue.
## Steps
1. Take the next free number — **across `main` and every open pull request**, not `main` alone.
Work sits on unmerged branches for days, so two people both reading `main` allocate the same
number; it happened twice in one hour between two machines, and the second collision reached
`main` with every check passing (issue 155). `cycle.py` now refuses two records sharing a number,
which catches a collision but does not prevent one. Create
`04-ISSUES/NNN-short-name/00-report.md`:
1. Open the issue in the tracker, through the mesh MCP server:
`mesh_call issue-tracker.open` with `title`, `report` (the symptom **as observed**, in plain terms, with the
evidence that it happened), optionally `located-in` (entries of the tracker's registered list of owners), and, for
an issue that gets a folder here, `record` — the folder's name without its number, `short-name`. The tracker
answers with the number `NNN`; a refused open spends none.
**An issue with a folder** then creates `04-ISSUES/NNN-short-name/00-report.md` under exactly that number, on a
branch:
```yaml
---
@@ -38,11 +60,21 @@ incident someone must **clear**.
---
```
Then the symptom **as observed**, in plain terms, with the evidence that it happened.
2. Investigate in `01-diagnosis.md` in the same folder — the trail, dated, including what was
ruled out. Move `status:` to `diagnosing`, then `located` once the owner is known.
3. Resolve. Set `status: resolved`, fill `fixed-by:`, and if the root cause was a design gap,
run playbook [02](02-graduation.md) and fill `amended-design:`.
Then the symptom as observed, as in the tracker's report.
**An issue wholly in the tracker** is done at this step: what follows happens through the tracker's verbs.
2. Investigate. For a folder-backed issue, the trail goes in `01-diagnosis.md` in the same folder — dated,
including what was ruled out — and `status:` moves to `diagnosing`, then `located` once the owner is known. For an
issue wholly in the tracker, the trail goes in `issue-tracker.note` and the status changes through
`issue-tracker.move`, which refuses `located` without a registered owner in `located-in`.
3. Resolve. Set `status: resolved` (in the frontmatter, or through `issue-tracker.move` for an issue wholly in the
tracker), fill `fixed-by:`, and if the root cause was a design gap,
run playbook [02](02-graduation.md) and fill `amended-design:`. **A core issue** — its `located-in`
names the controller, the node-engine, the tool runner, the SDK, or the catalogue's bus, forge or build
agent — resolves with `replay:`, the id of its replay in mesh-lab's replays register, proved to fail on
the commit before the fix and pass on it, or with `replay-none:` saying why none is possible
([ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md);
`cycle.py` checks it on a folder, and `issue-tracker.move` refuses the move without it).
## Rules
@@ -53,8 +85,9 @@ incident someone must **clear**.
- A fix that turns out to have broken something else is written back into the record that asked for
it, pointing at the new issue. Somebody arriving at a record to learn why the code is the way it
is must not have to already know there was a sequel.
- Renumbering a collision happens once, in the branch that lands last. Renumbering a branch whose
author is still pushing only moves the race.
- A number never moves. A folder carries the number the tracker gave; a folder whose number collides with
another (one numbered before the tracker, or by hand) is a fault, and `cycle.py` refuses two records
sharing a number.
- An issue whose answer is a general lesson should also be written to the knowledge base, so
the next person searching a symptom finds it. Both, not either.
- `status: wontfix` is legitimate and requires a sentence saying why.
+4
View File
@@ -24,6 +24,10 @@
## Rules
- Every merge is a human checkpoint, without exception.
- What the handoff leaves for later is an issue with an owner, named in the design or the decision that
leaves it — never a sentence saying "later". The design's `code:` names who owns what is built; the issue
names who owns what is not. [`cycle.py`](../checks/cycle.py) checks the decision record
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)).
- Never open a pull request unprompted. A permissions list saying it is allowed is not a
request.
- Work in an isolated worktree, never a shared checkout. A failed `cd` in a shared checkout
+3 -3
View File
@@ -52,7 +52,7 @@ Three questions, answered from the machine:
the container env-file: [/var/lib/postgres/superuser.env]
```
The host fills the hole on the machine, which is the only place both halves exist — the mesh
The node-engine fills the hole on the machine, which is the only place both halves exist — the mesh
discarded the value
([credentials and their rotation](../../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)).
A **provisioner** is the exception: it reads a password file, so it mounts the `.secret`
@@ -89,7 +89,7 @@ Six attempts, one real bug. Recorded because the ratio is the lesson: **the mesh
time and the scaffolding was not.**
- A shape existed in the language and no host implemented it, so every declaration carrying one
was refused whole — correctly, and the host said exactly that. **Nobody was reading the host's
was refused whole — correctly, and the node-engine said exactly that. **Nobody was reading the node-engine's
log.** Read it first; it is the only place that says why a machine did nothing.
- A blind find-and-replace renamed a provision in quotes and missed the same word bare.
- A command was tested only for the invocations that should fail, so it rejected every real one
@@ -99,7 +99,7 @@ time and the scaffolding was not.**
## Rules
- **Read the host's log before theorising.** A declaration that was sent and not applied says so
- **Read the node-engine's log before theorising.** A declaration that was sent and not applied says so
there and nowhere else.
- **A failing test is kept, not skipped.** It is the reproduction.
- **Never rotate during an adoption.** Rotation is a separate act, afterwards, deliberately.
+1 -1
View File
@@ -30,7 +30,7 @@ one-for-one — `mesh-catalog`, `mesh-sdk` and `mesh-tools` exist where the tabl
| Repository | Tier | Holds |
|---|---|---|
| `mesh-host` | 0 | **exists.** The node host — one statically linked binary, requiring nothing present ([ADR 0005](../02-DECISIONS/0005-the-node-host.md)) |
| `mesh-host` | 0 | **exists.** The node-engine — one statically linked binary, requiring nothing present ([ADR 0005](../02-DECISIONS/0005-the-node-host.md)) |
| `mesh-foundation` | 1 | the four pinned services, as declarations |
| `mesh-controller` | 2 | **exists.** The controller and its contexts — one of seven built ([ADR 0006](../02-DECISIONS/0006-the-substrate-and-the-control-plane.md)) |
| `mesh-surfaces` | 3 | tools, web, cli |
@@ -1,5 +1,5 @@
---
status: active
status: graduated
initiated: 2026-10-02
touches:
- 02-DECISIONS/0040-what-a-module-is.md
@@ -15,7 +15,13 @@ touches:
- 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: []
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
@@ -63,7 +69,7 @@ and the catalogue's shape ([as-is 10](../../03-DESIGN/00-as-is/10-module-catalog
- [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 this must settle before it graduates.**
**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.
@@ -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.
@@ -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.
@@ -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,124 @@
---
status: graduated
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
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
- 02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md
- 02-DECISIONS/0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md
- 02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md
- 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md
- 02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md
- 02-DECISIONS/0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md
- 02-DECISIONS/0232-a-binding-to-a-consumers-data-moves-only-by-a-person.md
became:
- 02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md
- 03-DESIGN/01-to-be/46-the-conversation-with-the-operator.md
---
# 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;
- **the conversation** (widened 2026-10-06): the mesh, its modules and its agents send messages and
**asks** (a question, a choice, a value, an approval), and the operator answers or writes first, over
channels chosen by their declared **capabilities** and by the operator's **work context**. Inputs
from outside (a mail arriving) share the same envelope;
- **asks that authorise:** one layer on top, for the answers that perform an action. These are checked
by the controller, and allowed only on channels whose capabilities prove that the operator answered.
### Why this effort widened rather than a new one opened
On 2026-10-06 the operator asked for these:
- approving and rejecting through Telegram;
- a generic shape in which Telegram simply holds a seat;
- input triggers alongside output channels;
- capabilities that decide which actions may travel on which channel;
- the work context as a factor in choosing the channel;
- asks that are not about permission at all.
That could have opened a new effort. It did not, because:
- every part of it hangs on this effort's open questions: Q1 (where a channel attaches), Q4
(presence), Q7 (answering back) and Q8 (what may leave);
- ADR 0227 kept answering back open **here**, and said this effort's graduation amends to-be 45 §5;
- an answer belongs to the message or ask this seat sends. Splitting them would leave two efforts each
owning half of one conversation.
Input that is not an answer (a mail arriving, a webhook) shares the envelope and the seat shape, and
is designed here only as far as that shape. Its consumers are later work.
## 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.
4. [Telegram, as the first holder](04-telegram-as-the-first-holder.md): making the bot, the bot
API's limits and semantics, what Telegram sees, and ten defects in the built code.
5. [The other holders, on the same axes](05-the-other-holders-on-the-same-axes.md): ntfy, Matrix,
Pushover, Gotify, mail, Signal, SMS and a dead-man service, as away channel and as the watcher's
path.
6. [A conversation with the operator](06-a-conversation-with-the-operator.md): messages, asks and
operator messages; asks' kinds and life; the kinded benches `channel` and `intake`; the capability
vocabulary; agents as participants; the migration.
7. [The work context, and the desk](07-the-work-context-and-the-desk.md): the signals, the routing
by context and its escalation, presence kept inside the mesh, and the desk as a full participant
(notification actions, the launcher's prompt).
8. [Asks that authorise](08-asks-that-authorise.md): the actions that need a person, the trust
capabilities, the three proofs and the three tiers, the controller's checks, why the desk needs a
factor, and what a compromise can reach.
9. [A proposed decision](09-a-proposed-decision.md): the recommendation, the operator's steps, the
tables, and a record ready for graduation.
@@ -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,128 @@
# 03 — Open questions
Each question names the options seen so far. None is decided here. Q1 and Q7 are taken further in
[06](06-a-conversation-with-the-operator.md) and [08](08-asks-that-authorise.md).
## Q1. The seat
**What speaks for the mesh to its operator?**
- **a.** One seat in the mesh's own set, held once for the mesh. Working name: `operator-channel`.
- It **accepts** `notify` (a work queue, as to-be 32 §5 designs `telegram-sender`), so a message
waits until a holder appears.
- It **emits** `delivered`, `acknowledged` and `resolved`.
- It **serves** `open` (what is unresolved now) and `history`.
- **b.** No seat: every source calls every channel. Rejected in advance, because each source would
learn every channel. This is the inversion ADR 0126 exists to prevent.
- **c.** Each channel as its own seat, with routing in the sources. Same objection as b, one level up.
Under a, the holder routes. The channels are modules that **contribute** themselves to the seat
(ADR 0210): a channel extends the seat, and so depends on it. Whether the holder is a module of its
own or part of the controller is open. A module keeps the controller small. The controller already
holds most of the facts.
## Q2. What a message is
The first shape seen: a **subject** (what it is about: a machine's role, a module, a plan), a
**kind** (out of touch, refused, stuck, late, …), a **severity**, a one-line **why**, a link to
the tool that shows more, and a **key** that makes it the same message the next time it is said.
- **Severity:** two levels (needs you now / when you can), or three (critical / warning / info)?
Every extra level is a routing rule somebody must keep right.
- **The key** is what makes deduplication possible. "Machine X out of touch" said every minute is
one message, still open, not sixty.
## Q3. The life of a message
open → (acknowledged) → resolved.
- **Deduplicate** by key while open.
- **Resolve** when the source stops saying it, or says it is over ("back in touch after 14 min"). A
channel that can edit its message (Telegram can) updates it in place rather than sending a second.
- **Acknowledge** from any channel that can answer, which stops escalation and repeats.
- **Repeat or escalate** an unacknowledged critical message after a while, to the next channel.
- **Where the open set lives:** the seat's own state, in a key-value bucket (to-be 32's `state:`), so
`open` answers after a restart.
## Q4. Routing and presence
- **By severity:** critical goes to every channel at once. A warning goes to the desk when the
operator is at one, otherwise to the phone, otherwise to the digest.
- **Presence:** "at a desk" needs a fact the mesh does not hold yet. Candidates: an unlocked
graphical session with recent input, read from the `node-lock-screen` and `node-login-manager`
seats' holders. Nothing more invasive.
- **Quiet hours:** a setting of the seat's holder (ADR 0174). Critical overrides it, or not, as the
operator chooses.
- **Rate:** a cap per hour per channel, with the excess folded into one summary, so a storm (a
network outage where every node is out of touch) arrives as one message naming many.
## Q5. The sources
The first sources are the facts in [01](01-what-the-mesh-already-knows.md), all in the controller
today:
- a machine out of touch;
- a declaration refused;
- a stuck failure;
- a plan late (once issue 230 gives a wait an age);
- an assignment that does not compose (issue 235);
- a host version split.
**How each becomes an event:**
- **a.** The controller emits an event per change of state, and the seat's holder consumes them.
- **b.** The controller calls `notify` itself.
With a, the controller learns nothing about telling: other consumers (a board, a log) get the same
facts, and the holder decides what is worth a message. With b, the controller decides severity.
**Modules as sources:** a module may `use` the seat to tell the operator something of its own
(a backup failed, a certificate is close to expiry), with the same message shape.
## Q6. The watcher's watcher
When the controller, the bus or the control node is what failed, nothing above runs. Options:
- **A dead-man signal:** the seat's holder sends a heartbeat out of the mesh (a ping to an external
heartbeat service), which alerts the operator by its own means when pings stop.
- **A second holder of the Telegram channel on another machine** that sends directly, without the
bus, when it stops hearing the controller for longer than a bound.
- **Each host** sending a last message itself when it loses the mesh for longer than a bound. This
needs the channel's secret on every machine, a cost to weigh.
The first is the cheapest and the only one that also covers "the whole house is offline".
## Q7. Answering back
Telegram, and Matrix, can carry the operator's answers.
- **Acknowledge and silence** are safe: they change only the message's state.
- **Commands** ("push the workstation", "show status") turn a chat account into a door to the
controller. If it ever comes, it needs:
- its own record;
- a narrow verb set;
- a check that the answer came from the operator's own account and chat;
- and probably a confirmation step.
The first version should probably answer with acknowledge and silence only.
## Q8. What may leave the mesh
Telegram, and any third-party channel, carries the words to someone else's servers. A message
names a machine, a module and a reason, which is operational detail.
- **What may a message contain?** Roles rather than addresses; no secrets, tokens or paths; a reason
in words. The rule must be enforced by the seat's holder, not hoped for from each source.
- **Is a self-hosted channel required for anything above a severity?**
- **The bot token and chat id** are secrets of the channel's module, delivered as any module secret is.
## Q9. How it is checked
A rule this effort produces must say how it is verified. Candidates:
- a message said twice with one key is one message;
- a resolved source resolves its message;
- a critical message reaches every channel within a bound;
- a message containing an address or a secret is refused;
- the dead-man signal fires when the holder is stopped.
Each is a test of the holder, or a live drill: stop a machine's host and time the message.
@@ -0,0 +1,222 @@
# 04 — Telegram, as the first holder of a channel
Telegram was the operator's first required channel ([02](02-the-channels.md)) and it is built:
the output seat's holder carries a Telegram client, and so does the watcher's watcher (to-be 45 §5).
Neither is configured, because no bot exists yet. This document is what the operator needs to make one,
what the mesh's use of the bot API must respect, and what the built code gets wrong against it.
[06](06-a-conversation-with-the-operator.md) makes Telegram one holder of a generic channel seat rather
than the subject of the design. Everything here stays true under that shape: it is the first holder's
analysis.
Facts are as of 2026-10-06, Bot API 10.3 (2026-08-24). Sources are listed at the end.
## What the mesh uses from Telegram
Two programs send, and neither reads anything back yet:
- **The output seat's holder**, on the control node. It sends a message when a condition is raised,
says it again as a reminder, and edits the first message in place when the condition clears.
- **The watcher's watcher**, on a machine that is not the control node. It sends straight to the bot
API over HTTPS when the controller's self-check or the bus has been silent past its bound.
Each holds a **bot token** as its own secret, issued outside the mesh (ADR 0228, `issued-by: outside`),
and a **chat id** as a setting. Each sends plain text: no `parse_mode`, so no markup to escape and none
to inject.
## Making the bot
Telegram has no developer console. A bot is made by talking to Telegram's own bot, BotFather, from an
ordinary Telegram account.
- **An account is required, and an account needs a phone number.** There is no other sign-up. The
number can be a virtual one bought on Telegram's own marketplace, at a price that makes it
irrelevant here.
- **`/newbot`** asks for a display name and a username. The username is 5–32 characters of Latin
letters, digits and underscores, must end in `bot`, and cannot be changed later.
- BotFather answers with the **token**: digits, a colon, then a key. Anyone holding it controls the bot.
- **`/token`** issues a new token for the bot. The old one stops working at once. This is the rotation
path, and it is the only one.
- **`/setjoingroups` → Disable** stops anyone adding the bot to a group. The mesh's bot talks to one
person; a group is only a way for someone else to see what it says.
- **Privacy mode** (`/setprivacy`) governs what a bot sees **in groups**: with it on, only commands
meant for it, replies to it and service messages. In a private chat a bot sees everything the person
writes. With groups disabled, privacy mode does not matter; leave it on.
### A bot cannot speak first
A bot cannot open a conversation. Until the person presses **Start** in the bot's chat, every send to
them fails with a "Forbidden" error. So the operator presses Start once, on each bot.
### Finding the chat id, safely
In a private chat the chat id equals the person's user id. There are two ways to learn it:
- **Read `getUpdates` once by hand.** After pressing Start, a call to `getUpdates` returns the `/start`
message with the chat's id. It works today. Its two weaknesses: the token appears in a command line
(and so in a shell's history) unless read from a file, and it trusts that the `/start` it sees is the
operator's. A bot's username is public, and anyone who finds it can press Start too.
- **A linking verb with a one-time code.** The holder makes a short code and answers with a deep link
(`https://t.me/<bot>?start=<code>`). The operator opens it on the phone; Telegram sends `/start <code>`.
The holder reads it by `getUpdates`, and binds **that** chat and **that** user id only if the code
matches and is fresh. This proves the chat belongs to whoever held the code, and it never shows the
token to anyone. It needs the holder to read updates, which approvals need anyway
([08](08-asks-that-authorise.md)).
The second is the one to build. The first is the stop-gap until it exists.
### One bot or two
The holder and the watcher each have their own secret. They can hold the same token or two.
**Two bots are better:**
- Revoking one does not silence the other. The watcher exists for the day the rest is broken, and
that day must not also be the day its token was rotated away.
- The phone shows which program spoke.
- **Only one program may read a bot's updates.** Two concurrent `getUpdates` callers on one token make
Telegram answer the older with HTTP 409, "terminated by other getUpdates request". The moment the
holder reads answers, the watcher could no longer share its token with anything that reads.
## The bot API, as the mesh uses it
### Limits
- **Rate.** Telegram's FAQ: "In a single chat, avoid sending more than one message per second." In a
group, 20 messages a minute. Broadcast across chats: about 30 a second. The holder's own cap is 20 an
hour, so the limit is never near.
- **Over the limit** the API answers HTTP 429 with `parameters.retry_after`, the seconds to wait
before the request may be repeated. Repeating early prolongs the wait.
- **Length.** A text message is at most 4096 characters after entity parsing. Longer is refused with
HTTP 400, not cut.
- **Callback data** on a button is 1–64 bytes ([08](08-asks-that-authorise.md)).
### Editing
- **`editMessageText`** replaces a sent message's text. For an ordinary bot message there is no time
limit. The 48-hour limit applies only to business messages, and deletion has its own 48-hour limit.
- **An edit notifies nobody.** No sound, no banner, and the message stays where it was in the chat's
history. This is why a clearing is cheap to say by edit. It is also why an edit must never be the
only way something **new** is said.
- **An identical edit is an error:** HTTP 400, "message is not modified". It is harmless and must be
read as success, not as a failure to fall back from.
- **A deleted message** answers "message to edit not found". Falling back to a new message is right
then.
### Loudness
Telegram has **no message priority**. The only lever is **`disable_notification`**: the message
arrives without sound. It cannot break through the phone's do-not-disturb, so an urgent message at
night is as quiet as the phone is set to be. Per-chat notification settings on the phone (a custom
sound, an exception to do-not-disturb) are the operator's, not the mesh's.
### Formatting
With no `parse_mode` the text is shown as written, and nothing in a message can be read as markup.
That is the right default for words that pass a content rule rather than a template. If markup is ever
wanted, `MarkdownV2` needs every reserved character escaped and fails the whole send on one miss, so
plain text or `HTML` with escaping are the safer options.
`disable_web_page_preview` was **deprecated in Bot API 7.0** in favour of
`link_preview_options: {is_disabled: true}`. It still works, but the mesh's messages carry no links
(the content rule refuses URLs), so the parameter can simply be dropped.
### Answering back
Answers reach a bot two ways:
- **Long polling with `getUpdates`**, over outbound HTTPS. Updates wait at Telegram for at most
24 hours.
- **A webhook** (`setWebhook`), which Telegram calls over HTTPS on port 443, 80, 88 or 8443. It may
carry a secret header (`X-Telegram-Bot-Api-Secret-Token`) that proves the call came from the webhook
that was set.
Only one of the two at a time. [08](08-asks-that-authorise.md) chooses between them.
## What Telegram sees
- **Everything in the message.** A bot chat is a "cloud chat": encrypted between the phone and
Telegram, and between Telegram and the bot API caller, and readable by Telegram. Bots cannot take
part in Telegram's end-to-end "secret chats".
- **That is what the content rule is for.** The holder refuses any message carrying an address, a
path or a secret's shape (to-be 45 §5), so what Telegram stores is roles, words and condition keys.
- **Who the operator is.** The account's phone number, and the addresses the phone and the sending
machines connect from. Since September 2024 Telegram's privacy policy says it may disclose a user's
IP address and phone number to judicial authorities on a valid order.
- **Machine names.** A condition key names the machine it is about, and so does the watcher's message
("told by mesh-watcher on …"). The content rule refuses host names with a top-level domain, not bare
machine names. To-be 45 says a message's subject is "a machine's role". Whether a bare machine name
may leave is a decision this effort has not taken; today it does.
## When Telegram is unreachable
- **A send fails at the transport** (no DNS, no connection, timeout). The holder keeps what it held
and tries again every minute. The watcher keeps what it owes and tries again at its next tick.
Neither loses a message while it runs.
- **A send fails because Telegram refuses** (400 or 403). It is permanent for that message. The holder
today treats it like a transport failure and tries again every minute, for ever (see the defects).
- **Telegram being down is invisible to Telegram.** The holder's status says the channel is failing.
The operator sees that only through another channel or by asking. This is what a second holder of a
different kind is for ([05](05-the-other-holders-on-the-same-axes.md)).
- **Telegram is blocked** in some countries and on some networks. An operator travelling should know
the mesh's phone channel may be one of them.
## Cost
- **Free.** No per-message charge. Telegram's paid broadcasts (above 30 messages a second) are far
out of range.
- **One account**, which the operator very likely already has.
- **Two secrets**, one token per bot, both `issued-by: outside`.
## The built code, checked against this
Read from the code repository's main branch on 2026-10-06: the holder's `telegram.go`, `outbox.go`,
`holder.go`, `content.go`, and the watcher's `telegram.go` and `watcher.go`. The two Telegram clients
are copies of each other, kept apart on purpose so the watcher depends on nothing it watches.
What is right:
- **Plain text**, with no `parse_mode`.
- **The token is kept out of every error.** The client rebuilds transport errors from their kind,
because the URL carries the token. It also strips the token from the API's own description.
- **Token and chat id are re-read at each send**, so accepting the secret or changing the setting needs
no restart.
- **A token's shape is checked.** A random value the mesh minted for an un-accepted secret is named as
that, not sent to Telegram to be refused.
- **A missing edit falls back to a new message.**
- **The holder caps itself** at 20 messages an hour and folds bursts into digests, far inside
Telegram's limits.
### Defects
| # | Where | What | Effect | Weight |
|---|---|---|---|---|
| D1 | holder: `telegram.go`, `outbox.go` | Nothing bounds a message to 4096 characters. A long summary, or a digest of long titles, is refused with 400. `failed` keeps the whole batch and retries it every minute. | One oversized message wedges the Telegram channel: everything queued behind it waits for ever. | high |
| D2 | holder: `holder.go` (`h.edit(old, "reopened", false)`) | A condition that clears and is raised again within ten minutes is said by **editing** the first message. On Telegram an edit notifies nobody. | A reopened urgent condition reaches the phone **silently**, high up in the chat's history. | high |
| D3 | holder: `outbox.go` (`r.Sent[name]` keeps only the first id) | Reminders and escalations are new messages, but clearing edits only the first. | The newest thing on the phone still says "STILL OPEN" or "NOW URGENT" after the condition cleared. The "CLEARED" is a silent edit, out of sight. | medium |
| D4 | both: `telegram.go` | `Message.Quiet` and `Message.Urgent` are ignored. `disable_notification` is never set. | A clearing, or a warning, rings as loudly as an urgent message. Telegram's only loudness lever is unused. | medium |
| D5 | both: `telegram.go` (`call`) | HTTP 429's `parameters.retry_after` is not read. The retry is a flat minute. | Harmless at the holder's cap. Under a real flood wait, repeating early prolongs it. | low |
| D6 | holder: `telegram.go`, `outbox.go` | Every refusal (400, 403) is retried like a transport failure. "message is not modified" on an edit is read as a failed edit, and a new message is sent instead. | Permanent errors loop every minute in the log. A no-op edit becomes a duplicate message. | low |
| D7 | both: `telegram.go` | `disable_web_page_preview` is deprecated since Bot API 7.0. | Works today. Moot, since no message carries a link. Drop it. | low |
| D8 | both: `telegram.go` (`call`) | The `json.Marshal` error is discarded. A non-numeric `message_id` (`json.Number`) makes the body empty. | A confusing refusal from Telegram instead of a local error. Ids come from Telegram, so it is unlikely. | low |
| D9 | watcher: `watcher.go` (`Tick`) | A "silent" message that could not be sent is overwritten by the "CLEARED" message when the signal returns. | The operator can receive "heard again" for a silence they were never told of. Better to say both, or one line saying it was silent for N minutes and is back. | low |
| D10 | both | `Ready()` is satisfied by a token and a chat id. It does not know whether the operator pressed Start, or whether the bot was blocked (403). | Status says "ready" until the first send fails. The watcher's own test verb is the only proof. A `getChat` check at status time would say it. | low |
D1 and D2 matter before the channel is configured. D1 can silence the channel. D2 silences exactly the
case (a flapping urgent condition) the operator most needs to hear.
## Sources
- Telegram, *Bots FAQ*: rate limits, paid broadcasts. https://core.telegram.org/bots/faq
- Telegram, *Bot API* (version 10.3, recent changes, `getUpdates` retention, `ResponseParameters`,
`setWebhook`, `link_preview_options`). https://core.telegram.org/bots/api
- Telegram, *Bot features*: BotFather, `/newbot`, `/token`, `/setprivacy`, deep linking.
https://core.telegram.org/bots/features
- `link_preview_options` replacing `disable_web_page_preview` (Bot API 7.0); the removal of the old
argument in python-telegram-bot v22. https://docs.python-telegram-bot.org/en/v22.0/telegram.ext.defaults.html
- "message is not modified" and "message to edit not found" in practice:
https://github.com/tdlib/telegram-bot-api/issues/400
- 409 "terminated by other getUpdates request":
https://community.home-assistant.io/t/help-on-telegram-extension-error-while-getting-updates-conflict-terminated-by-other-getupdates-request-make-sure-that-only-one-bot-instance-is-running-409/177544
- Telegram privacy policy change, September 2024:
https://www.bleepingcomputer.com/news/security/telegram-now-shares-users-ip-and-phone-number-on-legal-requests/
- Bots and secret chats; cloud-chat encryption:
https://www.kaspersky.com/blog/telegram-privacy-security/38444/
- Phone number required; anonymous numbers: https://en.wikipedia.org/wiki/Telegram_(software)
@@ -0,0 +1,186 @@
# 05 — The other holders, on the same axes
Every candidate is judged as a holder of the channel and intake seats in
[06](06-a-conversation-with-the-operator.md): which capabilities it can honestly declare
(the vocabulary is defined there), and what it needs. [02](02-the-channels.md) weighed the same
candidates before any of this was measured. This document replaces its reading of them with facts as
of 2026-10-06, and adds the question 02 could not ask: **can the operator answer through it, and
authorise an action through it?** ([08](08-asks-that-authorise.md)).
Two roles are judged separately, because they want different things:
- **The away channel:** the mesh's urgent messages and its asks, wherever the operator is.
- **The watcher's path:** the message that the mesh itself has gone silent. It must not depend on the
control node, the bus or the controller. The mesh observed has its controller, its bus and its mail
server on the anchor (the control node). Its Matrix server is on the home-server, behind a household
connection.
## The candidates
### ntfy
A small push server. Topics are published to over HTTP; the phone app subscribes.
- **Self-hosted vs the public server.** Self-hosted keeps the words on the operator's machines.
The public `ntfy.sh` takes no sign-up. Its free tier allows 250 messages a day **per IP address**,
shared with whoever else sends from that address. Paid tiers (from about $5–6 a month) give
reserved topics and higher quotas. An unreserved topic on the public server is readable by anyone
who guesses its name.
- **Phone delivery.**
- **Android:** through Google's FCM from the public server, or through the app's own long-lived
connection to a self-hosted server ("instant delivery"), which costs battery.
- **iOS:** cannot be reached by a self-hosted server alone. The server must name an upstream
(`upstream-base-url`, normally `ntfy.sh`), which receives a poll request carrying only a message
id and a hash of the topic, and has Apple wake the phone. The words do not pass through the
upstream. The dependency does.
- **Loudness:** five priorities. The highest gives "really long vibration bursts" and a pop-over on
Android.
- **Answers:** up to three action buttons. An `http` action makes **the phone** send a request,
which needs a route from the phone to the mesh and a credential carried inside the notification.
Nothing tells the server **who** tapped, only that someone holding the notification did. No free-text
reply.
- **As the watcher's path:** self-hosted, it fails with the machine it runs on. The public server
works, at the cost of a guessable topic or a subscription.
### Matrix (a homeserver is already one of the mesh's modules)
The module runs Conduit and Element Web, on the home-server.
- **Reach:** any Matrix client on the phone. Push goes from the homeserver to the client's **push
gateway**: for the stock Element apps, Element's gateway at matrix.org, which hands it to Apple or
Google. A self-hosted gateway needs a self-built app. UnifiedPush (via ntfy) is an option on Android.
- **Push support in Conduit has lagged.** Its own documentation long listed mobile push as missing,
and forks have since reworked pushers. Whether the running version pushes reliably is **unverified**
and must be measured before Matrix is relied on for anything urgent.
- **Answers:** free text, and reactions (`m.reaction` annotations) as one-tap choices. Clients add
emoji variation selectors, which must be normalised before a reaction is read as a choice.
- **Who answered:** the sender's Matrix id is authenticated **by the homeserver**, which the mesh
runs. That is strong for an account on the mesh's own server, and only as strong as that server.
- **Privacy:** end-to-end encrypted if the bot supports it. Otherwise readable by the homeserver,
which is the mesh's own.
- **As the watcher's path:** it survives the control node going down. It does not survive the home's
connection going down, and its phone push still depends on matrix.org's gateway.
- **Cost:** a bot account and its secret. No third party for the words.
### Pushover
A paid push service with a stable API.
- **Cost:** $4.99 one-time per platform after a 30-day trial. 10,000 messages a month per
application.
- **Limits:** 1024 characters, a 250-character title.
- **Loudness:** the strongest of any candidate.
- Priority 1 bypasses the user's quiet hours.
- Priority 2 ("emergency") repeats every `retry` seconds (at least 30) until acknowledged or until
`expire` (at most three hours). It returns a **receipt** that can be polled outbound to learn
whether, and when, it was acknowledged.
- **Answers:** acknowledgement only. No buttons, no reply.
- **As the watcher's path:** yes. It is outbound HTTPS from any machine, to a third party.
- **Where the words go:** to Pushover.
### Gotify
Self-hosted, Android only. Delivers over a WebSocket the app keeps open. There is no official iOS app,
and Apple's restrictions make a self-hosted iOS push impossible without a relay. It has no answer path
beyond opening the app. **Not pursued:** it covers less than ntfy and nothing ntfy does not.
### Mail through an outside provider
- **The mesh's own mail server is on the control node,** so it fails with it.
- **An outside relay** (an SMTP account at a mail provider) reaches the operator from any machine.
- **Urgency:** none. Mail is the digest and the record.
- **Answers:** a reply, slowly. **Who answered is weak:** a From line can be forged, and checking
DKIM only proves the operator's provider sent it.
- **Mail as an intake** (a new mail arriving) is a trigger in its own right
([06](06-a-conversation-with-the-operator.md)), whatever its weakness as a channel for asks that authorise.
### Signal, through `signal-cli`
- **An unofficial client,** and it needs its own phone number, registered with a captcha.
- **It must be kept current.** Signal's own clients expire after three months, and the server then
changes incompatibly. In March 2026 Signal began unregistering accounts whose client lacked a new
protocol feature, and every `signal-cli` account registered before that date was dropped.
- **Privacy:** end-to-end encrypted. Sender identity is strong (Signal's identity keys). Reactions
and replies both work.
- **Weight:** a phone number and a maintenance burden with a hard failure mode. It is the right
choice only for an operator who requires end-to-end encryption on the phone and accepts that burden.
### SMS through a paid gateway
The only candidate that reaches a phone with **no data connection**.
- **Cost:** per message, with an account at a gateway.
- **Privacy:** no encryption. Sender identity on replies is spoofable.
- **Its place:** the last resort of an outside dead-man service (below), which offers SMS and phone
calls on paid plans, rather than a channel of the mesh's own.
### An outside dead-man service
A service the mesh **pings**, which alerts by its own means when the pings stop. It is not a channel
of the mesh: it is the one thing that still speaks when **every** machine, or the home's connection
and the anchor together, are gone. [03](03-open-questions.md) Q6 named it the cheapest answer that
also covers "the whole house is offline".
- **Healthchecks.io** (also self-hostable, which defeats the point here) monitors 20 checks free,
without a card.
- It notifies through Telegram, Signal, Matrix, ntfy, Pushover, mail and others.
- Paid plans add SMS, WhatsApp and phone-call credits.
## The table
Capabilities are those of [06](06-a-conversation-with-the-operator.md). ✓ declared honestly,
— not, ~ conditional (the note says on what).
| Holder | reaches-away | loud | silent | edit | choice | reply | verified-sender | exact-render | code-factor | private | off the control node | cost |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Telegram | ✓ | — (do-not-disturb wins) | ✓ | ✓ | ✓ | ✓ | ✓ (user id) | ✓ | ✓ (code as a reply) | — | ~ (a holder on another machine) | free |
| desktop notifier | — | ~ (critical urgency) | ✓ | ✓ | ~ (actions, read by nobody) | — | — (any program of the account) | ✓ | — | ✓ | — | none |
| ntfy, self-hosted | ✓ | ✓ (priority 5) | ✓ | — | ~ (http action, phone → mesh) | — | — | ✓ | — | ✓ (iOS: relay sees ids) | — | a module |
| ntfy.sh | ✓ | ✓ | ✓ | — | ~ | — | — | ✓ | — | — | ✓ | free (250/day/IP) or ~$5/mo |
| Matrix (own server) | ~ (push unverified) | — | ✓ | ✓ | ✓ (reactions) | ✓ | ✓ (own homeserver) | ✓ | ✓ | ~ (E2E if the bot does it) | ~ (home-server, not anchor) | a bot account |
| Pushover | ✓ | ✓✓ (emergency, repeats) | ✓ | — | ~ (acknowledge only) | — | ✓ (for acknowledge) | ✓ | — | — | ✓ | $4.99 once |
| mail, outside relay | ✓ (slow) | — | ✓ | — | — | ✓ | — (forgeable) | ✓ | ~ (code in a reply) | — | ✓ | an account |
| Signal (`signal-cli`) | ✓ | — | ✓ | ✓ | ✓ (reactions) | ✓ | ✓ | ✓ | ✓ | ✓ | ~ | a number + upkeep |
| SMS gateway | ✓ (no data needed) | ✓ | — | — | — | ~ | — | ✓ | — | — | ✓ | per message |
## Reading it
**For the away channel and for asks,** Telegram is the only candidate that is all of these at
once: free, on both phone platforms, without a server of the mesh's own, and able to carry every tier
of authorising ask ([08](08-asks-that-authorise.md)), including a code as a second proof. Its price is that
Telegram reads the words, which is what the content rule is for.
- **Matrix** is the self-hosted equivalent for asks. It waits on a measurement of Conduit's push.
- **Signal** is the end-to-end-encrypted equivalent, at a maintenance cost that has already broken
every installation once this year.
**For waking the operator,** Pushover's emergency priority is the only thing that repeats until
acknowledged and gets through quiet hours. Telegram cannot. It is a reasonable **second** away holder
for urgent conditions only, if the operator wants to be woken. It cannot carry an answer beyond
"acknowledged".
**For the watcher's path,** the requirement is independence from what it watches:
- **Telegram, sent directly** from a machine that is not the control node, with its own bot, meets it
(to-be 45 §5).
- **ntfy self-hosted does not.**
- **Matrix only half meets it** (it is on the home-server, and its push goes through matrix.org).
- **What none of them covers** is the watcher's own machine, or the home's connection, going down
together with the anchor. Only an outside dead-man service covers that.
## Sources
- ntfy, *Configuration* (iOS upstream relay, FCM, access control): https://docs.ntfy.sh/config/
- ntfy, *Publishing* (priorities, actions, `http` action, message size): https://docs.ntfy.sh/publish/
- ntfy.sh pricing: https://ntfy.sh/#pricing. Its free-tier rate limit is per IP:
https://github.com/binwiederhier/ntfy/issues/1963
- Pushover API (length, quota, priorities, emergency retry/expire, receipts): https://pushover.net/api
- Pushover pricing: https://pushover.net/pricing
- Gotify, platform support: https://play.google.com/store/apps/details?id=com.github.gotify and
https://guancyxx.cn/en/blog/ntfy-vs-gotify-vs-nostr
- Element push gateway (Sygnal at matrix.org):
https://github.com/vector-im/element-android/blob/develop/docs/notifications.md
- Matrix reactions (`m.annotation`): https://github.com/uhoreg/matrix-doc/blob/aggregations-reactions/proposals/2677-reactions.md
- Conduit changelog: https://conduit.rs/changelog/
- signal-cli, registration with a captcha: https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha
- signal-cli, unregistration of outdated clients in 2026: https://github.com/AsamK/signal-cli/issues/1993
- Healthchecks.io pricing: https://healthchecks.io/pricing/. Its Telegram integration:
https://healthchecks.io/integrations/telegram/
@@ -0,0 +1,332 @@
# 06 — A conversation with the operator
The operator's directions, 2026-10-06, in substance:
- **Telegram is one output channel among many to come.** The setup must be generic, and Telegram
simply fulfils a seat.
- **The same holds for input:** a new mail, a new message from the operator.
- **Each channel has capabilities.**
- **Approval is only an example.** An agent may just as well want to ask a simple question, and "it
doesn't have to be permission related".
So the core of this effort is not a notifier and not an approval path. It is **a conversation with
the operator, held over channels**:
- the mesh, its modules and its agents **say** things and **ask** things;
- the operator **answers**, or **writes first**;
- each exchange goes over whichever channel is right for its needs and for where the operator is.
This document is that general model. [07](07-the-work-context-and-the-desk.md) is how the work context
chooses the channel. [08](08-asks-that-authorise.md) is one layer on top: asks whose answer performs an
action, and the checks that makes necessary. [04](04-telegram-as-the-first-holder.md) and
[05](05-the-other-holders-on-the-same-axes.md) are the first holders.
## What exists, and how it is shaped
Read from the catalogue's main branch on 2026-10-06.
- **The output seat's holder is one module doing three jobs.** It claims `operator-channel` (mesh
scope, serving `open`, `history` and `notify`). It consumes the controller's three condition events.
It holds the Telegram bot token as its own secret. It reaches the desktop through the `node-notifier`
seat's `send`.
- The router, the Telegram client and the desktop adapter are one process.
- `notify` is a served verb, not the work queue to-be 32 §3 sketched.
- **The desktop notifier** is the node seat `node-notifier`, held by the dunst module on each
graphical machine.
- **The watcher's watcher** has its own Telegram client and token, and is not assigned yet.
- **Nothing reads anything back.** The operator speaks to the mesh only through an agent session or a
shell.
## The three things said
- **A message:** the mesh tells the operator something. A condition raised, a reminder, a clearing,
a notice from a module. It expects no answer. It may be edited later (a clearing).
- **An ask:** someone wants the operator's input, of a declared kind. The answer goes back to whoever
asked.
- **An operator message:** the operator writes first. It is a message to an agent, a note to the mesh,
or an answer to an ask written in the thread instead of tapped.
Inputs from outside that are not the operator (a mail arriving, a webhook) are the same kind of
envelope as an operator message, with a sender that is not the operator. This effort designs their
shape only. Their consumers are later work.
## Asks
### Kinds
| Kind | The operator gives | Required capabilities of the channel |
|---|---|---|
| `yes-no` | yes or no | `choice`, or `reply` (read as yes or no) |
| `one-of` | one of up to eight labelled options | `choice`, or `reply` with the option's number |
| `text` | free text | `reply` |
| `number`, `date` | a value of that type, within bounds | `reply`. Parsed and checked by the seat's holder; asked again once if it does not parse. |
| `acknowledge` | "seen" | `choice` |
An ask whose answer **performs an action** (approve a retirement, delete data) is the same ask with
an **authorising** flag. It adds requirements of trust, which [08](08-asks-that-authorise.md)
defines. Everything else about it is as below.
### What an ask carries
- an id;
- the asker (its bus principal and its machine);
- the kind, with its options or bounds;
- the words;
- a priority (urgent or normal);
- optionally a timeout and a default;
- optionally a conversation handle (where the asker is talking with the operator);
- optionally a group, for batching.
The words pass the content rule like every message.
### Its life
**open → answered | defaulted | expired | cancelled**
- **Answered:** the first answer wins. Copies of the ask shown on other channels are edited to say
where it was answered.
- **Defaulted:** the timeout passed and the ask declared a default. The asker receives the default,
marked as a default, never as the operator's answer.
- **Expired:** the timeout passed with no default. The asker is told.
- **An authorising ask never defaults to performing.** It expires. ADR 0230's rule, "a timer is the
mesh acting alone again, only later", applies to every ask that authorises.
- **Cancelled:** the asker no longer needs it (`ask cancel`), or its owner sees it is moot (the
condition behind it cleared). Its copies are edited to "no longer needed".
### How the asker gets the answer
- The answer is emitted as **`ask-answered`**, with the ask's id and, where there is one, the
conversation handle.
- An asker may **wait**: `ask` with a wait of up to a few minutes returns the answer if it comes in
time, and otherwise returns the id.
- An agent working through a long task can **poll** `asks <id>`.
### Batching
- **Asks to the same channel within the burst window go out together.** A heading says how many are
open ("3 questions waiting"), and each ask follows as its own message, so each can be answered and
edited alone.
- **An asker may hold at most three open asks.** A fourth is refused to it, in words, so an agent in
a loop cannot flood the operator.
- **Asks count against the router's hourly cap** like messages. Answers do not.
### History
**`asks`** lists open asks, and closed ones for 30 days: the asker, the kind, the outcome, the channel,
and the answer. A free-text answer is the operator's own words. It stays in the router's state and is
never forwarded to a channel other than the one it came from.
## Who holds what
### The output seat's holder becomes the conversation's router
It holds `operator-channel` and gains asks:
- `ask` (create),
- `ask cancel`,
- `asks`,
- `answer` (called by intake holders, below),
- events `ask-opened`, `ask-answered`, `ask-closed`.
It **owns** every ask that authorises nothing.
An authorising ask is **owned by the controller** ([08](08-asks-that-authorise.md)). The router carries
it like any other, but its answer goes to the controller, which alone can perform.
### Where a channel sits: [03](03-open-questions.md) Q1, asked again
Q1 settled that **one seat speaks for the mesh** and that sources never learn channels. It assumed
each channel attaches by contribution. With many channels to come, and channels that answer, that is
the question to settle.
The mesh's precedents:
- A **seat** has one holder at its scope (ADR 0121, ADR 0126).
- A **node seat** has one holder per machine, and a verb's subject carries the machine (design 33 §4).
- A **bench** is a seat with several holders. The only one is `mesh-dns-resolver`: the same module,
one per machine. "Making another one is a decision, recorded" (ADR 0223).
- A **work queue** is shared by a seat's holders (ADR 0190).
- A **contribution** is content another module hands to a seat's holder (ADR 0210, ADR 0212).
The options:
- **a. Each channel contributes itself to the output seat** (Q1 a, to-be 45 §5).
- A contribution is content a holder places.
- A channel is running code: it holds a secret, keeps a connection, reads answers, fails on its own.
- Making it fit puts every channel's client back in the router. **Rejected.**
- **b. One seat per channel kind.** The router learns every seat; a new channel is a change to the
router. **Rejected.**
- **c. Channel modules found by a manifest field and called by module address.** Callers use seats,
never modules (ADR 0126). **Rejected.**
- **d. One monolithic notifier,** every channel built into the router. Shared secrets and failures, a
release per channel. **Rejected.**
- **e. A channel module per service, with its own approval or question path** (a "Telegram module"
that decides things). It locks the conversation to one service, and the next channel repeats it.
**Rejected.**
- **f. Kinded benches.** Two mesh seats, **`channel`** (out) and **`intake`** (in). Their holders are
different modules, each claiming a **kind** (`telegram`, `desktop`, `ntfy`, `matrix`, `mail`, …).
- One holder per kind; two claiming one kind is refused at registration.
- A verb's subject carries the kind, as a node seat's carries the machine:
`mesh.seat.channel.tool.send.<kind>`.
- A new channel is a new module claiming a new kind, with no change to the router.
- **Chosen.**
Option f needs a second bench, of a new sort (different modules, keyed by kind), which ADR 0223 says
must be decided. It also needs a claim that carries a kind and capabilities.
## The channel seat (out)
**`channel`**, mesh scope, a kinded bench.
### Served
- **`send`:** words, a priority, whether silent, an optional **ask block** (the kind, the options each
with an opaque token, the ask id) and an optional thread (the conversation handle, or the message
this replies to). Answers the channel's id for what it sent, or a refusal in words.
- **`edit`:** replace a sent message by its id, where `edit` is declared.
- **`standing`:** ready, not configured (naming what is missing, never a value), or failing (since
when, why); the last delivery; the declared capabilities.
### Emitted
- **`delivered`** and **`failed`**, the latter marked **permanent** or **transient** (defect D6 in
[04](04-telegram-as-the-first-holder.md)).
### Honoured
- The declared maximum length, by cutting and saying so (D1).
- Silence where declared (D4).
- An identical edit is a success (D6).
- No own secret in any error or event.
### The content rule
The content rule is the router's, applied before anything reaches a holder that does not declare
`private`.
## The intake seat (in)
**`intake`**, mesh scope, a kinded bench. A service that is read and written by one program (a
Telegram bot, [04](04-telegram-as-the-first-holder.md)) is held by one module claiming both seats under
one kind.
A holder turns what arrives into **one envelope**:
| Field | What it is |
|---|---|
| `id` | Unique, for deduplication. |
| `kind` | The holder's kind. |
| `what` | `message` (written first), `choice` (a button or a reaction), `reply` (written in an ask's thread), `mail`, `call` (a webhook), `seen` (activity, for the work context). |
| `sender` | The identity on that service, and whether the service authenticated it. |
| `operator` | Whether that identity is on the **controller's** list of the operator's identities. Filled from that list, never from the holder's own. |
| `conversation` | An opaque handle. Sending on it reaches the same chat, room or mail thread. |
| `in-reply-to` | The ask or message it answers, if any. |
| `payload` | The text, or the option's token. **No secrets:** a code for an authorising ask never travels in an envelope ([08](08-asks-that-authorise.md)). |
| `at` | When. |
**Answers go to the ask's owner by request and reply:**
- the router's `answer` for an ordinary ask;
- the controller's for an authorising one.
**Everything else is an event on the seat,** which consumers take by `what`:
- the router takes `seen` for the work context;
- an agent bridge takes `message` from the operator;
- a future mail rule takes `mail`.
**One gap to close:** the shared library cannot yet publish on a seat's event subjects (design 32 §1).
## The capability vocabulary
**Fixed and versioned:** `channel-capabilities/1`. A holder declares capabilities in its claim, and
the catalogue refuses a word outside the vocabulary. **Each capability has a contract test** the
holder's build runs and a **drill** its `standing` can run. One that fails its drill is reported and
withdrawn from routing until it passes.
The words are in three groups. The first two serve every conversation. The third exists only for
asks that authorise, and is defined in [08](08-asks-that-authorise.md).
### Delivering
| Capability | Promise | Test / drill |
|---|---|---|
| `deliver` | It arrives, or `failed` says why. | Against a test double; a live test message. |
| `reaches-away` | It reaches a phone away from the operator's machines. | Declared by kind; the operator acknowledges a drill. |
| `loud` | It can break through the phone's quiet hours. | The service's override is set for urgent. |
| `silent` | It can arrive without sound. | The service's silent flag is set. |
| `edit` | A sent message can be replaced in place. | Edit and read back, against a double. |
| `max-length:N` | Up to N characters arrive whole; longer is cut, and the cut is said. | N+1 characters give a cut message, not a failure. |
| `reaches-when-mesh-down` | Delivering needs neither the bus nor the control node. | Checked by the holder's placement and its send path. |
| `private` | The words stay on the operator's machines, or are end-to-end encrypted. | Declared by kind; reviewed. |
### Conversing
| Capability | Promise | Test / drill |
|---|---|---|
| `choice` | The operator can pick one offered option in one act, and the pick comes back. | A simulated tap gives a `choice` envelope with the option's token. |
| `reply` | The operator can answer in free text, and it comes back. | A simulated reply gives a `reply` envelope. |
| `threads` | An answer is tied to the message it answers. | A reply to message A carries A in `in-reply-to`. |
| `operator-first` | The operator can write to the mesh unprompted. | A simulated message gives a `message` envelope. |
### Trusting
`verified-sender`, `exact-render`, `code-factor` and `key-factor` are defined in
[08](08-asks-that-authorise.md). A channel without them still converses fully. It just cannot carry
an answer that performs an action.
## Agents in the conversation
An agent is a participant. It asks, and it receives.
- **An agent whose operator is at its own terminal** asks there, in the terminal. The seat is not
needed.
- **An agent working unattended** (in the background, on a schedule, or with the operator stepped
away) asks **through the seat**. The router puts the ask where the operator is now
([07](07-the-work-context-and-the-desk.md)): the desk if they are at it, Telegram if they are away
or talking through Telegram. The agent waits for, or polls, the answer.
- **An agent the operator talks to through Telegram** receives the operator's messages as intake
envelopes. It answers on the conversation handle. Its asks carry that handle, so they appear in the
same chat.
- **An agent's words reach the operator only as an asker's words,** and the operator's answer reaches
the agent only as the operator's. An agent relaying "the operator said yes" is not an answer to
anything. That is why an answer comes from a channel holder, never from the asker
([08](08-asks-that-authorise.md)).
## The watcher, in this shape
The watcher's watcher stays **outside** the seats, deliberately:
- it must speak when the bus and the control node are what failed;
- the seats live on the bus.
It is a minimal `deliver` + `reaches-when-mesh-down` sender with its own bot. It never reads, and never
asks. Its sibling outside the mesh is the dead-man service ([05](05-the-other-holders-on-the-same-axes.md)).
## From today to this shape
1. **Fix the first holder in place:** D1–D4 of [04](04-telegram-as-the-first-holder.md). No change of
shape.
2. **The operator configures Telegram** ([09](09-a-proposed-decision.md)). The mesh starts telling.
3. **The vocabulary and the kinded bench in the catalogue,** and seat events in the shared library.
4. **Split Telegram out** into its own module holding `channel` and `intake` under `telegram`. The
router keeps the desktop adapter as `channel/desktop` and `intake/desktop`.
5. **Asks in the router,** with buttons and replies on Telegram and actions on the desktop
([07](07-the-work-context-and-the-desk.md)). Agents can ask from here on.
6. **The work context** as the router's ordering ([07](07-the-work-context-and-the-desk.md)).
7. **Authorising asks in the controller** ([08](08-asks-that-authorise.md)).
8. **Operator-first messages,** and an agent bridge consuming them.
9. **Further holders as wanted:**
- Pushover for waking;
- Matrix once its push is measured;
- mail out for the digest, mail in as an intake.
The watcher changes only at step 1.
## What this revisits in [03](03-open-questions.md)
- **Q1:** channels attach as holders of kinded benches (f), not as contributions.
- **Q3:** the life of a message gains the life of an ask. `edit` and `silent` are declared, and decide
how a clearing is said (D2, D3).
- **Q4:** presence becomes the work context ([07](07-the-work-context-and-the-desk.md)).
- **Q7:** answering back is the conversation itself. Its authorising layer is
[08](08-asks-that-authorise.md).
- **Q8:** the content rule stays, applied by the router. Whether a `private` holder may be exempted
is left to graduation.
@@ -0,0 +1,117 @@
# 07 — The work context, and the desk
The operator, 2026-10-06:
- "If dunst can also show buttons, we could prefer to use desktop notifications instead of Telegram
for approval actions when working in a session."
- "The work context is an important factor when deciding the correct output channel."
[06](06-a-conversation-with-the-operator.md) decides **which channels may carry** a message or an ask:
those whose declared capabilities satisfy it. This document decides **which of those comes first**,
from where the operator is working. It also makes the desk a full participant in the conversation.
## The rule
> **A message or ask goes to the most direct channel in the operator's current context, among those
> whose capabilities already satisfy it. Unanswered in time, it escalates along a fixed chain.
> Context orders the candidates; it never adds one. Context never lowers the bar.**
The last sentence matters most for asks that authorise ([08](08-asks-that-authorise.md)). Being at the
desk never makes a click count as more than it proves.
## The signals
| Signal | Source | Read as |
|---|---|---|
| A graphical session unlocked, with input in the last 5 minutes, on machine M | The `node-lock-screen` seat's holder on M (the screen-lock module), whose tools already read the idle time and the lock state. Underneath: logind's `LockedHint` and `IdleSinceHint`. Proposed: an event on each change, not a poll. | **at the desk on M** |
| That session locked, or idle longer | the same | **not at the desk** |
| An agent asking from machine M | The ask's asker names its machine. An agent module's own "session active" event, when one exists. | **working with an agent on M**. It strengthens "at the desk on M"; alone it proves nothing. |
| A verified intake `message`, `reply` or `choice` in the last 15 minutes | The intake seat ([06](06-a-conversation-with-the-operator.md)) | **in a conversation** on that kind |
| An ask carrying a conversation handle | The ask | **that conversation**, whatever else is true |
| The hour, against quiet hours | The router's setting | **night**: only urgent wakes |
## The contexts, and where things go
"The away channel" is the operator's setting (Telegram, to begin with). "The loud holder" is an
optional second away holder for waking (Pushover, [05](05-the-other-holders-on-the-same-axes.md)).
| Context | An ask goes to | Urgent message | Warning | Unanswered or unacknowledged → |
|---|---|---|---|---|
| **In a conversation through Telegram** (the ask carries its handle, or Telegram activity is newer than any desk input) | that chat, in the thread | that chat | that chat, silent | after 10 min (urgent) or 1 h: also the desk, if active |
| **At the desk on M** (with or without an agent there) | the desk on M, if it can carry the ask; otherwise the away channel, and the desk says where it went | the desk on M, and the away channel silently | the desk on M | after 5 min (urgent) or 30 min: the away channel, with sound |
| **Away** (no unlocked active session, no recent conversation) | the away channel | the away channel | the away channel, silent | after 15 min (urgent): the loud holder, if configured |
| **Night, away** | non-urgent asks wait for the morning; urgent as away | the away channel and the loud holder | the morning digest | as away |
| **The desk locks while an ask is shown there** | moves at once to the away channel | — | — | — |
- **Every copy of an ask stays valid until one answer wins.** The others are edited to say where it
was answered.
- **The context's channel cannot carry the ask** (a free-text ask at a desk without a prompt, or an
authorising ask the desk cannot prove): the next in the chain carries it, and the context's channel
says where it went.
- **Nothing can carry it:** the router says so, as a condition of its own.
### Presence stays in the mesh
- **Presence facts are events on the bus,** consumed by the router.
- **They are kept as current state only:** a key-value entry per machine and per intake kind,
overwritten, never a history.
- **They never appear in a message's words,** so they never reach a channel that is not `private`.
- **No module keeps them as a timeline of the operator's day.** A consumer that wants one is a
decision of its own.
## The desk as a participant
Read from the catalogue's main branch and the tools' current documentation, 2026-10-06.
### What exists
- **The `node-notifier` seat** is held on each graphical machine by the dunst module. Its `send` runs
`notify-send --print-id` with an urgency, an application name and an optional replace id. It
carries **no actions** today.
- **libnotify's `notify-send`** (0.8 and later) takes `--action=NAME=Label`, repeatable, and `--wait`.
It prints the chosen action's name when one is chosen, and nothing when the notification is closed.
Underneath, the notification server emits `ActionInvoked` with the notification's id and the
action's key.
- **dunst shows actions:**
- `do_action`, which the module binds to the **middle** click, invokes the default or only action;
- otherwise it opens the **context menu**, which in this mesh is the `node-launcher` seat's
dmenu-compatible menu;
- `dunstctl action` and `dunstctl context` do the same from a command line.
- **The `node-launcher` seat's `menu` verb** shows a list in the operator's session and answers the
chosen line. A dmenu-compatible menu also accepts typed text that is not a listed line, which makes
it a free-text prompt.
- **The graphical session is X11.**
### What it takes
- **`send` gains actions:** a list of (token, label).
- **It still answers at once.** A notification may be answered minutes later.
- **The holder listens for `ActionInvoked`** and emits the chosen token as an event on its node seat.
- **The router's desktop adapter,** which holds `channel/desktop` and `intake/desktop`, turns that
into a `choice` envelope.
- **For `text`, `number` and `date` asks,** the notification's single action opens the launcher's
prompt, and what is typed comes back as a `reply`.
### What the desk can declare
| Group | Capabilities |
|---|---|
| Delivering | `deliver`, `silent` (low urgency), `loud` (critical urgency stays until dismissed), `edit` (replace id), `private` |
| Conversing | `choice` (actions), `reply` (through the launcher's prompt), `threads` (the ask's id is carried) |
| Trusting | **not** `verified-sender`. `exact-render` yes. `code-factor` (a prompt) yes. `key-factor` yes where a security key is plugged in. See [08](08-asks-that-authorise.md). |
So the desk carries **every ordinary ask**: yes or no, one of, text, number, date and acknowledge.
It needs no account anywhere. An agent working unattended on the workstation asks a clarifying question,
and the operator, at the desk, answers it in the notification.
It carries an **authorising** ask only with a factor the controller verifies itself, because a click
on an X11 desk proves that someone was there, not that the operator clicked
([08](08-asks-that-authorise.md)).
## Sources
- `notify-send(1)`, `--action` and `--wait`: https://man.archlinux.org/man/notify-send.1.en
- Desktop notifications and actions: https://wiki.archlinux.org/title/Desktop_notifications
- dunst documentation (mouse actions, `do_action`, the context menu): https://dunst-project.org/documentation/
- logind's `LockedHint`, `IdleHint`, `IdleSinceHint`:
https://freedesktop.org/software/systemd/man/org.freedesktop.login1.html
@@ -0,0 +1,247 @@
# 08 — Asks that authorise
Most asks inform their asker and change nothing ([06](06-a-conversation-with-the-operator.md)). Some
answers **perform an action**: approving a retirement, confirming that a binding moves, deleting data.
This document is the layer those asks need on top of the conversation. It is the controller's checks,
the trust a channel must prove, and what a compromise can reach.
The operator, 2026-10-06:
- "Make sure I can approve and reject stuff via the Telegram channel."
- In a terminal session with an agent, having to open Telegram is acceptable.
- But when talking to an agent **through** Telegram, the operator cannot switch to a desktop session.
- At the desk, desktop buttons would be preferred ([07](07-the-work-context-and-the-desk.md)).
## Where this stands against what was decided
- To-be 45 §5 says: "No answering back in this form".
- ADR 0227 kept it open on purpose: "routing by presence, quiet hours, **answering back** and the
external dead-man service stay open in 028, whose graduation amends to-be 45."
- So this answers 028's Q7. It does not reverse ADR 0227.
## What there is to authorise
Read from the controller's main branch on 2026-10-06. The condition store marks conditions only a
person resolves (`resolver: operator`): from the start for retirement, clean-up and binding conditions,
and once a healer's budget is spent.
| Action | The verb today | Asked for by | Reversible | Tier |
|---|---|---|---|---|
| Approve the retirement set waiting | `retire approve <node> <provider> --why` | `retire-waiting` (urgent) | yes: asking for a consumer again re-enables it | approve |
| Reject it | `retire reject … --why` | `retire-waiting` | yes | approve |
| Approve a set rejected before | `retire approve …` | `retire-rejected` (warning) | yes | approve |
| Confirm that a binding moves, once its data is moved | `pin <node> <provision> <from> <module>` | `binding-kept` (urgent) | the pin, yes. The data, not by the mesh. | approve |
| End a stuck plan | `plans stop` / `close <id> --why` | `stalled`, `sent-not-reported` once escalated | no, but it destroys nothing | approve |
| Send a machine its declaration by hand | `push <node> --why` | `sent-not-reported` once escalated | n/a | approve |
| Reset a bus consumer's position | `broker consumer-reset --why` | `consumer-behind` once escalated | no: messages skipped or redelivered | approve (graduation to confirm) |
| Try a healer's repair once more | the healer's ordinary path | any escalation (H1–H5), `healers-braked` | as the repair is | approve |
| Silence a condition | `conditions silence <key> --for --why` | any | yes, it ends by itself (at most 7 days) | acknowledge |
| Delete one retired consumer's data | `cleanup delete <node> <provider> <consumer> --why` | `cleanup-waiting` (after 30 days) | **no** | destroy |
| Delete everything retired longer than N days | `cleanup delete --older-than N --confirm --why` | `cleanup-waiting` | **no** | destroy |
| Change the operator's identities, the away channel, or a factor's enrolment | (new) | — | yes, but it changes who may authorise | destroy |
The build queue verbs and `replay --register` are not offered as asks: no condition asks for them.
## Trust, as capabilities and proofs
The conversation's vocabulary ([06](06-a-conversation-with-the-operator.md)) gains four words used
only here:
| Capability | Promise | Test / drill |
|---|---|---|
| `verified-sender` | The holder proves the answer came from the operator's own account on that service, by the service's authentication, through a holder **no agent shares**: not on the operator's account, not on a machine where agents run as the operator. | A choice from an identity not on the list is dropped and reported. The holder's placement is checked. |
| `exact-render` | The ask is shown as the controller rendered it, by the holder itself. No asker or agent composes the words the operator authorises. | Rendered text equals the controller's, byte for byte, against a double. |
| `code-factor` | The holder can carry a code the operator types to the controller, by request and reply, and never judges it. | A code is never in an event, and is deleted from the conversation where the service allows. |
| `key-factor` | The holder can run a security key's assertion over the controller's challenge, and hand the controller the result. | The challenge is the controller's, and the signature is checked by the controller. |
From these, three **proofs** that the operator is the one answering:
- **P1, a verified sender:** a Telegram tap, through a holder on a machine no agent runs on.
- **P2, a code:** from the operator's authenticator, verified by the controller.
- **P3, a key touch bound to the ask:** the controller's challenge is a hash of the ask's id and the
state digest. A FIDO2 assertion with user presence (the key waits for a touch) is checked against the
operator's enrolled credential. It proves a physical touch for **this** ask and no other.
## Three tiers
| Tier | Required |
|---|---|
| **acknowledge** | `choice`, `exact-render`. Silencing is open to agents already, and announced; a proof adds nothing. |
| **approve** | `choice`, `exact-render`, and **one** proof (P1, P2 or P3). Single use, bound to the exact state shown, expiring when that state changes or after 24 h. |
| **destroy** | `choice`, `exact-render`, and **two** proofs, at least one of them P2 or P3. Valid 10 minutes after it is shown; at most one destroy answered per 10 minutes. |
| Where the operator answers | Proofs it offers | acknowledge | approve | destroy |
|---|---|---|---|---|
| Telegram | P1 (tap), P2 (code as a reply) | tap | tap | tap and code |
| the desk, with a security key | P3 (touch), P2 (code in a prompt) | click | click and touch | click, touch and code |
| the desk, without a key | P2 (code in a prompt) | click | click and code | not possible: carried by the away channel |
| an agent's terminal | none | none | none | none: an agent asks, it never answers |
| the console (a shell) | P2 (code) | — | break-glass: a code | none |
### The rule
1. **Every authorising verb declares its tier** in the controller's verb table.
2. **An authorising ask is offered only on a channel whose capabilities satisfy its tier.** An answer
arriving from any other channel is refused, and the refusal is said there.
3. **The operator's away channel must satisfy every tier.** The self-check verifies it. A setting
that would make a tier possible only at a desk is refused, unless the operator chose that for the
tier explicitly. While working through Telegram, everything can be completed in Telegram.
4. **The work context chooses among the channels that qualify; it never makes one qualify**
([07](07-the-work-context-and-the-desk.md)).
5. **No agent authorises.** An agent asks. It holds no verb that performs an authorising action. The
record names the agent that asked.
## Why the desk needs a factor
- **X11 does not isolate the clients of one display.** Any of them can inject input (the XTEST
extension, as `xdotool` does) and read keystrokes.
- **`dunstctl action` invokes a notification's action** for any program of the account.
- **The desktop holder runs as the operator's account,** whose files, the holder's bus credential
included, every agent on that account can read.
So a click at the desk, its `ActionInvoked` and the desktop holder's envelope can all be produced by
an agent. An unlocked session with recent input proves a person was there, not that the person
clicked. The desk declares no `verified-sender`. A factor the **controller** verifies gets around
that.
| Factor at the desk | Can an agent on the account fake it? | Judgement |
|---|---|---|
| **A security key's touch, bound to the ask** (P3) | No: the touch is physical, and the signature covers this ask's id and state. | **Preferred.** One click, one touch, no phone. Needs a key and an enrolment. |
| **A code typed into the launcher's prompt** (P2) | It cannot know the code. On X11 it can read keystrokes and race to use the code first, and each step's code is accepted once. | **Acceptable** without a key. Costs picking up the phone. The race is a residual risk until the session leaves X11. |
| **The screen's unlock or a fingerprint** | Yes: only the local holder sees the result. | **Rejected.** |
The desk would earn `verified-sender` only if agents ran under an account of their own, without the
operator's display, session bus or holders' credentials, on a compositor that isolates clients. That
is a question for the agent modules' placement. It is noted, not proposed.
## The controller holds authorising asks
Today a grant covers a whole verb (`seat:mesh-controller.retire` allows approving and listing alike).
A hand-act records `by` from the calling bus principal, which for a channel would be the module, not
the person. Both call for the controller to hold these asks itself.
- **The verb table gains a field.** The controller's verb definition (a name, a description, input and
output schemas) gains **`authorises`**: the tier, and the arguments that make up the exact state a
person must see (for `retire approve`, the set of consumers).
### Three verbs
- **`authorise request`:** anyone may call it, an agent or the router on a condition's behalf. It
carries the action (verb and exact arguments), why, and optionally a conversation handle.
- The controller renders the ask: what is asked, the exact state, who asks, what each option does.
- It stores it with an opaque id (10 random base32 characters), its expiry and a digest of the
state shown, and emits `ask-opened` with the controller as owner.
- The router carries it like any ask. **Nothing is performed.**
- **`authorise answer`:** only intake holders are granted it. It carries the id, the option, the
sender's identity, and the code or key assertion where the tier needs them. The controller checks,
and refuses at the first failure:
1. the ask is open and not expired;
2. the caller is the holder of an intake kind, by the controller's own seat records, never by the
request's claim;
3. that kind's declared capabilities, from the controller's records, satisfy the tier, and the
proofs present are enough;
4. a P1 answer: the sender is on the controller's list of the operator's identities for that kind;
5. a code: valid for the current or previous 30-second step, and unused;
6. a key assertion: it verifies against the enrolled credential, over this ask's challenge, with the
user-presence bit set;
7. the state **now** has the digest it had when shown. Otherwise the ask is void and a new one is
requested.
Then it performs the action as itself, records the hand-act, closes the ask with a compare-and-set
(so a second answer on another channel loses), and emits `ask-answered`.
- **`authorisations`:** open and recent authorising asks.
### The authorising verbs refuse to be called directly
`retire approve|reject`, `cleanup delete`, and `pin` while a `binding-kept` names it refuse a caller
unless the call comes through `authorise answer`, or carries a valid code as **break-glass** at the
console. Break-glass is recorded as such, and announced on every channel.
- `retire approve` must accept the set it approves (`expect`). Today it re-reads the set when it
runs, so "approve what you were shown" (ADR 0230) does not hold end to end.
- `conditions silence` stays callable. A silence an agent sets is said on the away channel, with its
why.
### The record
The hand-act gains:
- **`via`:** the kind and the holder;
- **`requested-by`:** the agent principal or the condition key;
- **`ask`:** the id;
- **`proofs`:** which of P1, P2 and P3 were present.
`by` reads "the operator, as <kind> identity <id>". Every copy of the ask is edited to the outcome
("approved by the operator on telegram at 14:02 UTC"), and its buttons are removed.
## Telegram, carrying them
- **Buttons.** `callback_data` is at most 64 bytes, so a button carries only `a1:<id>:<option>`.
Everything else is in the controller.
- **A tap** arrives as a `callback_query` with the tapping user's id and the chat. The holder:
1. drops it, and reports, unless both are the operator's;
2. calls `answerCallbackQuery` at once, because the phone shows a spinner until it does;
3. calls `authorise answer`;
4. edits the message to the outcome or the refusal.
- **A destroy ask** answers the tap with a `ForceReply` prompt for the code. The holder reads the
operator's reply, deletes it from the chat (bots may delete incoming messages in private chats), and
hands it to the controller by request and reply. It is never put in an event.
- **Long polling, not a webhook.**
- `getUpdates` needs no route into the mesh.
- A stolen token can steal updates, and a second reader shows as HTTP 409, but it cannot inject an
update.
- A webhook needs a public route, and a stolen token can redirect it.
- The offset is kept in the holder's state, and ids are single use anyway.
- **Placement.** The Telegram holder runs where no agent runs as the operator. Its own declaration of
`verified-sender` depends on it.
## Agents
- **In a terminal:**
1. The agent calls `authorise request`.
2. The router carries the ask to where the operator is ([07](07-the-work-context-and-the-desk.md)):
the desk, with a factor, or Telegram.
3. The agent sees `ask-answered`.
Nothing the agent says counts.
- **Through Telegram:** the request carries the conversation handle, so the ask appears in the same
chat, rendered by the holder, and the operator taps in place, destroy included.
## If something is compromised
| What is lost | What the attacker can do | What limits it |
|---|---|---|
| The bot token | Read what the bot is sent from then on. Steal taps (visible as 409). Send the operator fake messages. | It cannot call `authorise answer`. Revoke with BotFather's `/token`. |
| The operator's Telegram account, on a new device | Approve or acknowledge. | Telegram's two-step password. Every authorisation is announced on the other channels. Approve is reversible. Destroy needs a code. |
| The phone, unlocked | Everything, including destroy, if the authenticator is open on it. | An authenticator behind biometrics. One destroy per 10 minutes, announced. Backups (research 030). A setting turning destroy off for the away channel. |
| An agent on the operator's account | Click at the desk, read the desk's keystrokes, read the desktop holder's credential. | No tier accepts the desk without a code or a key touch the controller verifies. A code read off X11 is good for one step, and the race is said above. |
| A channel module, or its bus account | Forge P1 for approve or acknowledge. | It cannot forge P2 or P3. Destroy needs one of them. |
| Telegram itself | Read the words. In principle, forge a tap. | The content rule. Destroy needs P2 or P3, which Telegram never sees. |
**The factors' secrets are the controller's own.**
- The TOTP seed is made by the mesh. It is enrolled by showing its URI once, only to a terminal, and
never through a channel or an event.
- A security key is enrolled by registering its credential's public key.
- Re-enrolling either is a destroy ask.
## The other holders, for authorising
- **Matrix** can declare everything Telegram does: reactions, replies, a sender authenticated by the
mesh's own homeserver, codes. It is the self-hosted carrier once its push is measured.
- **ntfy:** an `http` action makes the phone call the mesh, with a credential inside the notification.
Nobody knows who tapped, and there is no reply. No tier.
- **Pushover:** acknowledgement, read back by polling a receipt. At most `acknowledge`.
- **Mail:** a reply can carry a code, but the sender is forgeable. No tier, except as break-glass.
## Sources
As in [04](04-telegram-as-the-first-holder.md) and [07](07-the-work-context-and-the-desk.md), and:
- Bot API `callback_data` (1–64 bytes), `answerCallbackQuery`, `ForceReply`, `deleteMessage`:
https://core.telegram.org/bots/api
- `answerCallbackQuery` is required even with no text: https://gramio.dev/telegram/methods/answercallbackquery
- X11 and its clients (input injection, keystroke reading):
https://hackindex.io/services/x11/exploitation/x11-session-hijacking and
https://www.semicomplete.com/projects/xdotool/
- `fido2-assert` (user presence, verifying an assertion):
https://developers.yubico.com/libfido2/Manuals/fido2-assert.html
- ntfy `http` actions: https://docs.ntfy.sh/publish/
- Pushover receipts: https://pushover.net/api
@@ -0,0 +1,238 @@
# 09 — A proposed decision, and what the operator does now
This is the effort's reading as of 2026-10-06, written so that playbook 02 can turn it into a record
and amend to-be 45 §5. It is a proposal: nothing here is decided until it graduates.
## The recommendation, short
1. **The mesh holds a conversation with the operator over channels.** It sends messages and asks;
the operator answers or writes first. A message, an ask and an operator message are the three
things said ([06](06-a-conversation-with-the-operator.md)).
2. **Channels and intake are seats.** One kinded bench each, `channel` and `intake`. Each holder is a
module of its own, claiming a kind and declaring capabilities from a fixed, versioned vocabulary,
each with a contract test and a drill.
3. **Asks are general.** Yes or no, one of, text, number, date, acknowledge, each requiring its own
capabilities. They have timeouts, defaults, cancellation, batching and history. The answer returns
to the asker as an event, and an asker may wait.
4. **The output seat's holder is the router.** It chooses among the channels that satisfy a message or
ask, by **work context**: in a Telegram conversation, Telegram; at the desk, the desk; away, the
away channel. Unanswered, it escalates. **Context never lowers the bar** ([07](07-the-work-context-and-the-desk.md)).
5. **The desk is a full participant.** Notification actions and the launcher's prompt carry every
ordinary ask, with no account anywhere.
6. **Asks that authorise are a layer on top,** held by the controller. Three tiers (acknowledge,
approve, destroy), and three proofs the operator is answering (a verified Telegram sender, a code,
a security key's touch bound to the ask). The controller checks the channel's declared
capabilities from its own records and verifies codes and key assertions itself
([08](08-asks-that-authorise.md)).
7. **No agent authorises.** An agent asks; the operator answers where they are. On an X11 desk, where
an agent could click for them, only a code or a key touch counts. In a Telegram conversation, the
ask appears in that chat and is completed there, destroy included.
8. **Telegram is the first holder and the away channel.** It is free, on both phone platforms, needs
no server of the mesh's own, and is the only candidate that carries every tier
([04](04-telegram-as-the-first-holder.md), [05](05-the-other-holders-on-the-same-axes.md)).
9. **The watcher's watcher stays outside the seats,** with its own bot, on a machine that is not the
control node. A free outside dead-man service covers the rest. Pushover is the optional holder for
waking. Matrix is the self-hosted carrier once its push is measured.
10. **The built Telegram code needs D1–D4 fixed before it is configured**
([04](04-telegram-as-the-first-holder.md)).
## What the operator does
Minimal, in order. Steps 1–6 are possible today. The desk needs nothing from the operator: no account,
no bot.
1. **Make two bots.** In Telegram, open BotFather and run `/newbot` twice: one for the mesh's
conversation, one for the watcher. Keep each token out of agent sessions.
2. **Close them to groups:** `/setjoingroups` → Disable, for each bot.
3. **Press Start** in each bot's chat. A bot cannot write first.
4. **Turn on Telegram's two-step verification,** if it is not on.
5. **Find your chat id.** Until the linking verb exists, call `getUpdates` once, reading the token from
a file, and take `message.chat.id` from your `/start`. It is the same for both bots.
6. **Give the mesh the values,** through the controller, never on disk:
- accept the mesh bot's token as the output seat's holder's own secret `telegram-token`, and set
its `telegram-chat-id`;
- assign the watcher to a machine that is not the control node, accept the watcher bot's token,
and set its chat id;
- push both machines, and run each module's test verb.
7. **Later, once built:**
- make a free dead-man check, and give its ping address to the mesh;
- enrol an authenticator, at a plain terminal;
- optionally, enrol a security key, to approve at the desk with one touch.
## What would change in the code (proposal, not built)
- **Now, in the output seat's holder and the watcher:** D1 (cut at 4096 characters), D2 (a reopening
is a new message), D3 (a clearing reaches the newest message), D4 (`disable_notification` for
warnings and clearings), then D5–D10.
- **Catalogue:**
- a claim carries `kind` and `capabilities`;
- a kinded bench refuses two holders of one kind;
- the vocabulary `channel-capabilities/1` and its contract tests;
- the shared library publishes on a seat's event subjects.
- **Router:**
- asks (`ask`, `ask cancel`, `asks`, `answer`, and the events `ask-opened`, `ask-answered`,
`ask-closed`);
- the work context, from presence events;
- the escalation chain.
- **Desktop:** `node-notifier.send` gains actions, and the holder emits the chosen one. The
screen-lock holder emits lock and idle changes.
- **Controller:**
- `authorises` on the verb definition;
- `authorise request`, `authorise answer`, `authorisations`;
- `retire approve` takes `expect`;
- the authorising verbs refuse direct calls without a code;
- the hand-act gains `via`, `requested-by`, `ask` and `proofs`;
- the operator's identities, the TOTP seed and enrolled keys as its own;
- a self-check probe: the away channel satisfies every tier.
- **Modules:**
- a `telegram` module holding `channel` and `intake` (long polling, buttons, replies, `ForceReply`
codes, a linking verb with a one-time deep-link code), placed where no agent runs as the operator;
- an agent bridge for operator messages;
- the dead-man ping.
## The tables
### Ask kinds × what a channel needs
| Ask | deliver | choice | reply | threads | Trust (only if it authorises) |
|---|---|---|---|---|---|
| a message (no answer) | ✓ | | | | |
| acknowledge | ✓ | ✓ | | | tier acknowledge: `exact-render` |
| yes-no | ✓ | ✓ or | ✓ | ✓ | tier approve or destroy, if it authorises |
| one-of | ✓ | ✓ or | ✓ (a number) | ✓ | as above |
| text, number, date | ✓ | | ✓ | ✓ | never authorises |
### Authorising tiers × proofs
| Tier | Needs | Telegram | Desk with a key | Desk without a key |
|---|---|---|---|---|
| acknowledge | `choice`, `exact-render` | tap | click | click |
| approve | + one proof | tap (P1) | click + touch (P3) | click + code (P2) |
| destroy | + two proofs, one of them P2 or P3 | tap + code (P1 + P2) | click + touch + code (P3 + P2) | carried by Telegram |
### Surfaces × declared capabilities
✓ declared, ~ conditional, blank not.
| Surface | deliver | reaches-away | loud | silent | edit | choice | reply | threads | operator-first | verified-sender | exact-render | code-factor | key-factor | private | reaches-when-mesh-down |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| telegram | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (placed apart from agents) | ✓ | ✓ | | | |
| desktop (dunst + launcher) | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | ✓ | ✓ | ~ (a key plugged in) | ✓ | |
| matrix (own server) | ✓ | ~ (push unmeasured) | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ~ | |
| ntfy | ✓ | ✓ | ✓ | ✓ | | ~ (http action) | | | | | ✓ | | | ~ | ~ (ntfy.sh) |
| pushover | ✓ | ✓ | ✓ | ✓ | | ~ (acknowledge) | | | | ✓ | ✓ | | | | ✓ |
| mail (outside relay) | ✓ | ✓ | | ✓ | | | ✓ | ✓ | ✓ | | ✓ | ~ | | | ✓ |
| an agent's terminal | — | | | | | | ~ (relayed) | | | | | | | | |
| the console | — | | | | | | | | | | ✓ (own output) | ✓ | | | |
| the watcher's sender | ✓ | ✓ | | | | | | | | | | | | | ✓ |
### Where things go, by context
The full table is in [07](07-the-work-context-and-the-desk.md). In one line each:
- **in a Telegram conversation:** that chat;
- **at the desk:** the desk, or the away channel when the desk cannot carry it;
- **away:** the away channel;
- **night:** urgent only;
- **unanswered:** the next in the chain;
- **the desk locks:** it moves away.
## The proposed record
> **Title.** The mesh holds a conversation with its operator over channels that are seats, chosen by
> capability and work context, and an answer that performs an action is authorised by the controller.
>
> **Context.** The output channel was built in its minimal form (ADR 0227, to-be 45 §5): one holder
> carrying the router, a Telegram client and a desktop adapter, and no answering back.
> - The operator expects many channels and many inputs.
> - The operator wants agents and modules to ask questions, not only for permission.
> - The operator wants the work context to choose the channel, and every authorisation completable on
> the away channel.
> - Today, actions that need a person are verbs any granted principal can call, agents included, and
> a hand-act records the calling principal, not the person.
>
> **Considered options.**
> 1. Channels as contributions to the output seat. A channel is running code with a secret and
> answers, not content a holder places.
> 2. One seat per channel kind. The router learns every seat.
> 3. Channel modules found by a manifest field. Callers use seats, never modules (ADR 0126).
> 4. One notifier with every channel built in. Shared failure, shared secrets, a release per channel.
> 5. A per-service module with its own approval or question path. Locks the conversation to one
> service.
> 6. **Kinded benches for out and in, a capability vocabulary, a router that holds the conversation
> and orders channels by work context, and an authorising layer held by the controller. Chosen.**
>
> For answers:
> - a webhook, or **long polling (chosen)**;
> - authorising actions called directly by the channel module, or **requested and answered through
> the controller (chosen)**;
> - trusting a desktop click, or **requiring a code or a key touch the controller verifies (chosen)**.
>
> **Decision.**
> - **The conversation.**
> - Two mesh seats are kinded benches: `channel` (send, edit, standing) and `intake` (one envelope
> per input).
> - Each holder is its own module, claims one kind, and declares capabilities from
> `channel-capabilities/1`, each with a contract test and a drill.
> - The output seat's holder routes messages and asks (yes-no, one-of, text, number, date,
> acknowledge) by required capability, then by work context, then by severity, escalating when
> unanswered. It says when nothing can carry something.
> - Asks have timeouts, defaults (never for an authorising ask), cancellation, a per-asker limit,
> batching and 30 days of history. Answers return to the asker as events.
> - Presence is current state on the bus, never a history, never in a message's words.
> - **Asks that authorise.**
> - The controller holds them: `authorise request` (anyone; never performs), `authorise answer`
> (intake holders only), `authorisations`.
> - It checks the caller's declared capabilities from its own records, the sender against its own
> list of the operator's identities, codes and key assertions itself, and the exact state's digest,
> single use and expiry.
> - Tiers acknowledge, approve and destroy require none, one and two proofs. The away channel must
> satisfy every tier, checked by the self-check, unless the operator chose otherwise for a tier.
> - No agent authorises. The authorising verbs refuse direct calls except as break-glass with a
> code.
> - The hand-act records `via`, `requested-by`, `ask` and `proofs`.
> - **First holders.**
> - Telegram is the first holder of both seats and the away channel, placed where no agent runs as
> the operator.
> - The desktop holds both for the desk.
> - The watcher's watcher stays outside the seats with its own bot, and an outside dead-man service
> is pinged by the self-check and the watcher.
>
> **Consequences.**
> - The output seat's holder loses its Telegram client to a module of its own and gains asks and the
> work context.
> - The catalogue gains `kind` and `capabilities` on a claim, and a second kind of bench.
> - `node-notifier.send` gains actions.
> - The controller's verb definition gains `authorises`, and `retire approve` takes the set it
> approves.
> - Agents' grants lose authorising verbs.
> - To-be 45 §5 is amended: the operator answers.
> - Telegram sees the words, held to the content rule, and never a factor's secret.
>
> **How it is checked.**
> - **Catalogue tests:**
> - an unknown capability is refused;
> - two holders of one kind are refused;
> - each declared capability's contract test runs in its holder's build.
> - **Router tests:**
> - an ask goes only to channels whose capabilities satisfy it;
> - context reorders but never adds a channel;
> - an authorising ask never defaults;
> - a cancelled ask's copies are edited;
> - a fourth open ask from one asker is refused.
> - **Controller tests,** one per refusal of `authorise answer`:
> - from a non-intake principal;
> - from a kind that does not meet the tier;
> - from an identity not on the list;
> - with too few proofs;
> - with a used code;
> - with a key assertion over another ask's challenge;
> - with a stale digest;
> - a second answer.
>
> Also: a direct `retire approve` without a code.
> - **Self-check probe:** the away channel meets every tier.
> - **Live drills:**
> - an agent's question answered at the desk;
> - the same with the desk locked, answered on the phone;
> - an approve and a destroy on a test condition, answered on the phone, with the hand-acts read.
@@ -0,0 +1,72 @@
---
status: graduated
initiated: 2026-10-04
touches:
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
- 03-DESIGN/00-as-is/15-the-agent-and-its-licences.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
- 02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md
became:
- 02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
---
# 029 — The agent configured through its module
## What
Everything about the operator's coding agent that can be configured is configured through the agent
module's tools, and reaches the machines as **one plugin the mesh serves**:
- subagents, skills, slash commands, hooks, output styles and tool servers;
- the agent's settings and its instructions.
Each item is registered once, from any machine, and goes to one machine, several, or all of them,
including a machine that joins later. The mechanism is the one the module already uses for tool
servers: the registration is kept in the module's state on the bus, and each machine's instance writes
what applies to it. The operator chose the plugin route on the day this effort opened.
**Three scopes** (the operator's direction, the same day):
- **mesh:** in the mesh's plugin and the managed files, on every machine;
- **node:** the same places, rendered for one machine or a list of them;
- **home:** placed in the operator account's own agent directory on a machine, beside what the
person writes there by hand.
Instructions follow the same scopes: the mesh's piece, the node's piece, then further customisation per
machine. See [03](03-options.md).
## Why
The vendor gives the agent a machine-wide directory for its settings, its tool servers and one
instruction file, and **nothing machine-wide for skills, subagents, commands or hooks**. Those exist
only in a home or a project. So today they are copied into each home by hand, and they drift and go
stale. [01](01-what-is-configured-today.md) measures that on four machines.
A plugin is the vendor's own unit for carrying all of those at once. A machine-wide setting can name
a marketplace and enable a plugin from it. If the module serves the plugin and its own managed settings
enable it, the mesh gets a machine-wide place for everything the vendor left home-only, and the home
stays the person's ([ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)).
## What it touches
- **The agent module's design** ([to-be 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)):
its managed directory, its state, and its tools.
- **Module state** ([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)):
where registrations are kept, and which file content fits in a bucket.
- **Contributions** ([ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)):
a module other than the agent's, say the forge's, wanting the agent to have a skill for it. That is a
contribution to the agent's seat, not a file it writes.
- **The managed settings key the operator sets** ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)),
which a settings tool would write rather than a hand-composed settings layer.
## Documents
- [01 — What is configured today](01-what-is-configured-today.md): the evidence.
- [02 — What the vendor allows](02-what-the-vendor-allows.md): plugins, marketplaces and managed
settings, as documented, with sources.
- [03 — Options](03-options.md): where each kind of item goes, how it is registered and stored, and
the questions a decision has to answer.
- [04 — What was confirmed](04-what-was-confirmed.md): the checks, tried on one workstation.
@@ -0,0 +1,51 @@
# 01 — What is configured today
Measured on 2026-10-04 on four machines that run the agent module: two workstations, the control node
and a home server. The figures count what sits in each operator account's agent directory in its home,
outside the module's managed directory.
## What sits in the homes
| what | workstation A | workstation B | control node | home server |
|---|---|---|---|---|
| rule files (`rules/`) | 4 | 2 | 2 | 1 |
| skills of the person's own (`skills/`, beside the vendor's synced ones) | 6 | 2 | 2 | 2 |
| subagents (`agents/`) | 0 | 1 | 0 | 0 |
| slash commands (`commands/`) | 0 | 0 | 0 | 0 |
| plugin marketplaces known | 1 | 2 | 1 | 1 |
## What that shows
- **Six files the design says the operator removes are still on every machine.** To-be 36 §1 lists the
predecessor's rule files and skills and leaves their removal to the operator, "once, on each
workstation". On all four machines, the two predecessor skills are present, byte-identical to each
other:
- one that switches licences through tools that no longer exist;
- one that names the predecessor's forge.
So a session can still load a skill whose every instruction fails.
- **One instruction, three versions.** The predecessor's node-identity rule file is on three machines,
with three different contents. It was written per machine and then left alone.
- **Two instruction sets that contradict each other, loaded together.** On a workstation, one session
reads two sets of instructions:
- the module's managed instruction file says to search the mesh's records first;
- the predecessor's rule files in the home say to search the predecessor's knowledge base first,
through tools that are no longer served.
Both are loaded, and neither says the other is stale.
- **A subagent exists on one machine only.** A reviewer for module definitions was written on one
workstation. The other three machines cannot use it, and nothing says it exists.
- **Settings are per home, and so per machine.** The agent's auto-mode environment, the rules that
decide which actions the agent may take unasked, is written in one home's settings file. It
describes another organisation's cloud, and it answers for this mesh's forge only through a list of
trusted domains. When the agent refused a merge the operator had approved, the only lawful fix was
a managed key ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)).
The agent could not change its own settings, and nothing else in the mesh could either.
## What already works the way this effort wants
Tool servers. A server registered through the module's register tool is kept in the module's state
on the bus, keyed `all.<server>` or `<node>.<server>`. Every instance watches that state and writes what
applies to it into the managed tool-server file, and a machine that joins later takes it at its first
start. Today one server is registered there, for one workstation. That is the shape this effort extends
to everything else.
@@ -0,0 +1,106 @@
# 02 — What the vendor allows
Read from the vendor's documentation on 2026-10-04; the agent installed on the machines measured in
[01](01-what-is-configured-today.md) was a 2.1 release. Each fact names the page it came from. Where the
documentation is silent, this says so. A fact that a design rests on is to be confirmed on one machine
before it is built on (see [03](03-options.md), *What to confirm first*).
## What a plugin can carry
A plugin is a directory with a manifest in `.claude-plugin/plugin.json` and, beside it, any of:
- skills, slash commands and subagents;
- hooks;
- tool servers (`.mcp.json`) and language servers;
- output styles, workflows, themes and monitors;
- a `bin/` directory;
- a `settings.json`.
Its components are namespaced by the plugin's name, so a subagent `reviewer` in a plugin `mesh` is
`mesh:reviewer`, and it never collides with a person's own of the same name.
— *plugins/manifest-reference, plugins/loading (name conflicts)*
**What a plugin cannot carry:**
- **Settings.** Only two keys of a plugin's `settings.json` take effect: the default agent and the
subagent status line. The rest are dropped. — *plugins/manifest-reference, settings*
- **Permission rules.** Not documented as a plugin capability.
- **Instructions.** A `CLAUDE.md` at a plugin's root is not loaded, and the validator warns about it.
Instructions reach a session through skills only. — *plugins/manifest-reference, standard layout*
## Marketplaces, and a marketplace on the machine's own disk
A marketplace is a `marketplace.json` listing plugins and where each comes from. Its sources include:
- a relative path inside the marketplace;
- a forge repository, a git URL or a subdirectory of one;
- a package from a registry;
- an archive over HTTPS;
- the output of a command.
**A marketplace can be a directory on the machine.** Its plugins with relative paths are **loaded in
place**, not copied into the cache. An edit takes effect at the next session start, or at
`/reload-plugins` in a running session, and the plugin's version need not change.
— *plugins/marketplace-reference (marketplace sources), plugins/loading (in-place and copied plugins)*
A plugin from any other source is copied into a cache in the home, under
`plugins/cache/<marketplace>/<plugin>/<version>/`. — *plugins/loading*
## What managed settings do with plugins
These keys work in the machine-wide managed settings file — *plugins/org*:
| key | what it does |
|---|---|
| `extraKnownMarketplaces` | registers a marketplace on every session of the machine |
| `enabledPlugins` | `true` installs and enables a plugin; `false` blocks and hides it at every scope. The managed value outranks every other scope |
| `strictKnownMarketplaces`, `blockedMarketplaces` | allow-list or block-list of marketplace sources |
| `strictPluginOnlyCustomization` | refuses skills, subagents, hooks and tool servers that come from neither a plugin nor managed settings |
| `allowManagedHooksOnly` | runs only the hooks from managed sources |
| `disableSideloadFlags` | blocks loading a plugin from the command line |
| `syncClaudeAiPlugins` | stops plugins synced from the vendor's web account |
**Installed without anyone being asked.** Once the settings reach a machine, the marketplace is
registered and the plugins installed at the next session start. A non-interactive run installs them in
the background. Managed plugins do not wait for the workspace trust prompt. — *plugins/org*
## What the managed settings file honours besides
`permissions` (with its default mode and the switch that disables bypassing it), `autoMode`, `hooks`,
`env`, `model`, `statusLine`, `outputStyle`, `apiKeyHelper`, and the managed-only switches for permission
rules, hooks and tool servers. — *managed-settings*
That `autoMode` is honoured from the managed file is documented. That it changes what the agent
refuses on these machines is still to be seen ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)
left that open).
## Tool servers: the exclusive file wins over a plugin's
When the managed tool-server file is present, as the module writes it, it is **exclusive**: only its
servers load. The vendor's web connectors load too when a managed key allows them. **A plugin's
`.mcp.json` servers are blocked.** — *managed-mcp (exclusive control)*
So a tool server registered through the module stays in the managed tool-server file. Putting it in
the plugin would silently stop it loading.
## Variables inside a plugin
- `${CLAUDE_PLUGIN_ROOT}`: the plugin's directory.
- `${CLAUDE_PLUGIN_DATA}`: a directory that survives updates.
- `${CLAUDE_PROJECT_DIR}`: the project's root.
These resolve in hook commands, tool and language server configuration, and the content of skills,
subagents and commands. A plugin's declared options (`userConfig`) can be marked sensitive; the agent
asks the person for them and stores them itself. — *plugins/manifest-reference (environment variables)*
## Reload
A running session does not see a changed plugin until `/reload-plugins` or a new session.
`/reload-plugins` reloads skills, subagents, hooks and servers. It does not restart monitors.
— *plugins/loading*
## Not documented
- a machine-wide directory for bare skills, subagents or commands. Only a plugin enabled by managed
settings puts them machine-wide;
- permission rules or instructions carried by a plugin.
@@ -0,0 +1,159 @@
# 03 — Options
The route is chosen: a plugin the mesh serves. What is left open is where each kind of item goes, how it
is registered and kept, and what the module does about what it finds in the homes.
## Scopes (the operator's direction, 2026-10-04)
The plugin is not the only place the module manages. **The agent's configuration is managed at three
scopes, and each item is registered at one of them:**
| scope | where it lands | reaches |
|---|---|---|
| **mesh** | the mesh's plugin, and the mesh's part of the managed files | every machine running the agent, including one that joins later |
| **node** | the same plugin and managed files, as rendered on that machine | one machine, or a list of them |
| **home** | the operator account's own agent directory on a machine (`~/.claude`) | that account on that machine |
Each machine renders its own plugin from the registrations that apply to it, so a node-scoped skill sits
in the same `mesh` plugin as a mesh-scoped one, on that machine only. The home scope places an item
where the person's own items live, without the plugin's prefix, as if written there by hand. The
difference is that the mesh knows it placed the item and can change or remove it.
**Instructions follow the same scopes.** The agent reads the managed instruction file first, then the
home's instruction file and its rule files, then the project's. These are concatenated, not overridden:
a later file does not cancel an earlier one, which is how the contradiction measured in
[01](01-what-is-configured-today.md) came about.
- **The mesh's piece** sits in the managed instruction file and is the same on every machine: how a
session on this mesh works, and the conventions.
- **The node's piece** sits in the same file, rendered per machine: its role, and instruction sections
registered for it.
- **Further customisation per machine** sits in the home: a rule file the module places, at the home
scope, beside whatever the person writes there by hand.
**What the home scope needs from [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md).**
That ADR already lets the mesh own what it places in a home and hold the rest as found. So the module
owns each home item it placed, path by path, recorded in its state. It never writes, renames or
removes an item it did not place. A home item with the same name as one the person made is refused
at registration, never overwritten.
## Where each kind of item goes
[02](02-what-the-vendor-allows.md) puts a hard limit on the plugin: it carries skills, subagents, commands,
hooks, output styles and language servers, but no settings, no permission rules, no instructions, and
no tool server the exclusive managed file does not list. So there are four places, not one:
| kind | goes to | why there |
|---|---|---|
| skills, subagents, slash commands, output styles | **the mesh's plugin** | the only machine-wide place the vendor has for them |
| hooks | **the mesh's plugin**, with the scripts beside them | a hook's script can live in the plugin and be named through `${CLAUDE_PLUGIN_ROOT}`. A hook in the managed settings would need its script placed somewhere else |
| tool servers | **the managed tool-server file**, as today | the exclusive file blocks a plugin's servers |
| settings and permission rules | **the managed settings file**, beside the mesh's keys ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)) | a plugin's settings are dropped |
| instructions | **the managed instruction file**, in sections | a plugin's instruction file is not loaded |
The plugin is reached through two managed keys the module already owns the file for:
`extraKnownMarketplaces`, naming a marketplace directory the module writes, and `enabledPlugins`, set to
`true` for the mesh's plugin. Neither is the operator's to set. Like the attribution key, they are the
mesh's keys and outrank whatever the operator sets.
### Option A — one plugin
Everything the mesh serves is in one plugin, `mesh`, so every invocation reads `mesh:<name>`. That is
simple, and the name says where an item came from.
### Option B — a plugin per source
One plugin for what the operator registers, and one for what other modules contribute (below). An item
then says in its name whether a person or a module definition put it there. But the operator has two
prefixes to remember, and an item has two owners to ask about.
*Leaning:* A. Where an item came from belongs in the module's list tool, not in the item's name.
## Who registers an item
- **The operator, through the module's tools**, from any machine, for one, several or all of them. The
pattern is the tool-server register tool's, extended to every kind:
- `claude_code_<kind>_list`, `_register`, `_unregister` for skills, subagents, commands, hooks, output
styles and instruction sections;
- `claude_code_settings_get` / `_set` and `claude_code_permission_allow` / `_deny` / `_ask` /
`_remove` for the managed settings.
- **Another module, through the agent's seat** ([ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)).
The forge's module wanting the agent to know how pull requests are made here contributes a skill. It
declares the contribution in its definition, and the controller renders it to the agent's holder on
each machine where both run. That depends on the agent module holding a seat; today it holds none.
The two meet in the one plugin. A contribution and a registration with the same name are refused at
registration, and the list tool shows the owner of each.
## Where a registration is kept
The tool-server registrations live in a key-value bucket the module declares
([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)),
keyed `all.<name>` or `<node>.<name>`. Skills differ: a skill is a folder, and it can carry scripts
and reference files beside its main file. Every message on the bus is limited to about a megabyte.
1. **One value per item.** The item and its files go in one value, refused above a limit well under
the bus's. It is simple, it fits the module's existing state, and every item measured in
[01](01-what-is-configured-today.md) takes 20 KB or less on disk,
the vendor's synced skills aside. But a skill with a large reference file cannot
be registered at all.
2. **The bus's object store for files, the bucket for the item.** Large files are stored in pieces and
the item names them. Nothing in the mesh uses the object store yet, so ADR 0201 would need
extending.
3. **A repository on the forge.** The plugin is built from a repository, and registering an item is a
commit. This is reviewable and versioned. But a register tool would have to write to the forge,
and the forge would sit on the path to every machine.
*Leaning:* 1 now, with the limit stated and checked at registration. 2 when an item outgrows it. 3
mixes the operator's configuration into the code review cycle, which it does not need.
## Where the plugin is written
The module owns the managed directory, so the marketplace goes under it, written whole by the
module's code:
- the marketplace file;
- one plugin directory beside it.
It is loaded in place, so a change takes effect at the next session, or at `/reload-plugins` in a
running one. Nothing is copied into the home.
## What the module does about what it did not place
[01](01-what-is-configured-today.md) found stale predecessor files on every machine. The module did not
place those, so they are held as found (ADR 0182). It can:
- **report** them: a status tool lists the home's skills, subagents, commands and rule files, says which
the mesh placed, and names those that duplicate a mesh item or call tools no longer served;
- **import** one on request: `claude_code_<kind>_import` takes an item from one machine's home and
registers it at a scope the operator chooses. A skill written by hand on one workstation becomes a
mesh, node or home item in one call. Removing the original stays the person's act.
`strictPluginOnlyCustomization` would make home items stop loading altogether, and home-scoped items
with them. That is the operator's choice to make through the managed settings, not a default of the
module.
## What to confirm first, on one workstation
1. A directory marketplace named in the managed settings, with its plugin enabled there, loads with
no prompt, in place, in an interactive session and in a non-interactive one.
2. The plugin's skills, subagents and commands are offered under `mesh:`, beside the home's own
items, with no collision.
3. A hook in the plugin runs, with its script found through `${CLAUDE_PLUGIN_ROOT}`.
4. The exclusive tool-server file still loads the console, and a server in the plugin does not load,
as documented.
5. `autoMode` in the managed settings changes what the agent refuses (ADR 0213's open point).
6. The account can read the marketplace in the managed directory, which root owns.
7. The managed instruction file and a home rule file the module placed are both loaded, in that order.
## Questions a decision has to answer
- One plugin or one per source (leaning: one).
- How a registration is kept, and the size limit (leaning: one value per item, with a stated limit).
- Whether the managed settings are set through tools writing the module's state, or through the
controller's settings layer as ADR 0213 has it. If both, which one wins on the same key.
- Whether the agent module holds a seat, so that other modules can contribute to it.
- The three scopes, and the home scope's ownership rule: the module owns exactly the home paths it
placed, recorded in its state, and refuses a name the person already uses.
- Whether settings take the same three scopes. The home's settings file is the person's own, so it is
left out unless the operator chooses otherwise.
@@ -0,0 +1,38 @@
# 04 — What was confirmed
On 2026-10-04, on one workstation running the agent's 2.1 release, the checks [03](03-options.md)
listed were tried with a probe. The probe was a directory marketplace holding one plugin named
`mesh`, which carried:
- a skill, a subagent and a slash command;
- a session-start hook running a script in the plugin;
- a tool server in the plugin's own `.mcp.json`.
The managed settings were set through the agent module's `managed_settings`
([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)),
on that machine's layer only, and sent by a push. The module rendered them into the managed file
within seconds, without a restart.
| # | check | result |
|---|---|---|
| 1 | a marketplace named in the managed settings, with its plugin enabled there, loads with no prompt, in place | **confirmed.** A non-interactive session registered the marketplace and enabled the plugin at start, with nothing asked. The plugin was not copied into the home's plugin cache and is not listed among installed plugins: it is read where it lies |
| — | a change to the plugin needs no version bump | **confirmed.** A skill added to the plugin's directory after the first session was offered by the next one |
| — | a running session takes the plugin without being restarted | **seen.** An interactive session that was already running when the plugin was enabled offered its skills and subagent after the operator logged in again in that session, without a restart |
| 2 | the plugin's items are offered under its name, beside the home's | **confirmed.** `mesh:probe-skill`, `mesh:probe-agent` and the command `/mesh:probe`. A collision with a home item of the same name was not tried |
| 3 | a hook in the plugin runs, its script found through `${CLAUDE_PLUGIN_ROOT}` | **confirmed.** The session-start hook ran its script. The vendor's validator asks for the placeholder to be quoted |
| 4 | the exclusive tool-server file still loads the console, and a plugin's server does not | **confirmed.** The session started the console and the registered servers, and never the plugin's server |
| 5 | `autoMode` in the managed settings changes what the agent refuses | **very likely.** With a probe rule forbidding one harmless read-only command, a session that was already running had that command refused moments after the rule was rendered, though the refusal gave no reason. It also suggests the rule reached a running session without a restart. A clean check needs a session whose only difference is the rule; the agent may not start one in its own auto mode, so it is left to the operator |
| 6 | the account can read the managed directory, which root owns | **confirmed** by what already runs: every session reads the managed instruction file from that directory |
| 7 | the managed instruction file and the home's are both loaded, managed first | **confirmed** by what already runs: a session lists the managed instruction file first, then the home's instruction file, then each of the home's rule files |
## What this changes in the options
- The plugin route works as documented, with no file copied into the home. The module's managed
directory can hold the marketplace.
- **A tool server stays in the managed tool-server file.** Check 4 closes that.
- **Undoing a managed setting is not the agent's to do.** When the probe was over, the agent tried to
clear its own machine's settings layer, and its own auto mode refused that as self-modification.
Setting it had been allowed only because it made the agent stricter. So the tools that set the
agent's settings and permissions are tools the operator calls, and the agent calling them for
itself is refused by the vendor's own guard. A design must not assume an agent can tidy up after
itself.
@@ -0,0 +1,29 @@
---
status: graduated
became: [03-DESIGN/01-to-be/43-backups-against-mistakes.md, 02-DECISIONS/0214-backups-guard-against-mistakes-and-stay-on-the-machine.md]
initiated: 2026-10-05
touches: [04-ISSUES/242-the-mesh-has-no-backups, 02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md, 02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md, 03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md]
---
# 030 — Backups against our own mistakes
**What.** How the mesh keeps restore points of its data: what is copied, how often, how long it is
kept, full or incremental, where it lives, who runs it and how it is proven to restore.
**Why.** Issue 242: nothing in the mesh backs anything up, and when one misread file dropped every
database on the control node (issue 241), the newest copies were migration leftovers nine to twelve
days old, found by searching a disk.
**The scope is set by the operator, and it is narrow on purpose: mistakes, not disasters.** A
backup here protects against what a person, an agent or the mesh itself does wrong — a dropped
database, a deleted bucket, a bad migration, a file overwritten — and not against a disk dying or a
building burning. Losing data to a disaster is an accepted risk. That removes the off-site copy, the
cross-site transfer and the second key-holder from the problem, and leaves the part that would have
saved the night of issue 241.
**What it touches.** The store providers (each knows how to dump its own store consistently), the
host's scheduled steps (ADR 0053, built), the node-wide composition pattern a module's jail already
uses (to-be 31), and the operator's output channel (research 028) for saying a backup failed.
Documents: [01 — what the mesh holds](01-what-the-mesh-holds.md),
[02 — options and a proposal](02-options-and-proposal.md).
@@ -0,0 +1,55 @@
# 01 — What the mesh holds, measured 2026-10-05
Four machines: the control node (hosted, holds every public service), the home server (media, home
automation, a large ZFS pool), a workstation and a laptop. Sizes are apparent sizes, rounded.
## Nothing backs anything up
On every machine: no backup tool other than `rsync` and `pg_dump` is installed, no systemd timer and
no cron line mentions a backup, dump or snapshot. Every live data directory is on ext4 except the
home server's pool (ZFS), so a filesystem snapshot is available only there.
## The control node — the data that cannot be recreated
| what | size | how it changes |
|---|---|---|
| object store (file-sync service's files, photos) | 183 GB | slowly; files added, rarely rewritten |
| forge (repositories, attachments, its database) | 7 GB | daily |
| MS SQL Server databases | 5 GB | daily |
| mail (mailboxes; accounts in postgres) | 2 GB | continuously |
| postgres (forge, mail admin, identity, file-sync index, analytics, catalogue, licence manager) | ~2 GB | continuously |
| file-sync service's own directory, website, analytics | ~4 GB | slowly |
| MongoDB | 0.4 GB | daily |
| the mesh's own records (controller, vault, module state) | ~1.5 GB | continuously |
Recreatable and not worth copying: container images (210 GB), the artifact registry (40 GB — every
artifact is rebuilt from git), a 115 GB speed-test bucket and a 7 GB pre-migration object-store copy.
Free space: 833 GB on the filesystem holding the data, 2.9 TB on a second one.
## The home server
MS SQL Server 80 GB, postgres and a self-hosted backend platform ~3 GB, chat server 6 GB, home
automation, network controller and time-series data each under 2 GB, and the media services'
libraries (tens of GB, mostly cover art and metadata they re-fetch). The 89 TB media library is
replaceable by its nature and out of scope. Free: 31 TB on the pool, 453 GB on the system disk.
## The workstation and the laptop
The workstation has 142 GB under its services directory and 31 GB of container volumes; the laptop
3 GB. Mostly development; what among it is data nobody can regenerate is for each module to say.
## Between the sites
Control node to home server ~285 Mbit/s, home server to control node ~19 Mbit/s. Irrelevant now that
backups stay on the machine whose data they hold, recorded because it is why an off-site copy would
have been expensive.
## What issue 241 says about the requirement
- The mistake was noticed within hours. A restore point a day old would have lost a day.
- The restore had to go *beside* the live database, not over it, and that worked well.
- A copy of a live postgres data directory needed a throwaway server of the right version to read;
a logical dump would have restored directly.
- The data that survived was the data outside the dropped stores. A backup that lives inside the
store it protects — a database's own snapshot table, a bucket's own versions — dies with a drop.
@@ -0,0 +1,74 @@
# 02 — Options and a proposal
## Who decides what is backed up
1. **A central list** on the backup holder. Rejected: it is the attentiveness rule ADR 0030
rejected — a store added and not listed is silently unprotected.
2. **Each module declares its own data, a node-wide holder composes them.** The pattern of to-be 31
(a module declares its jail; the mesh composes them per node). A store provider declares *how*
to take a consistent copy (a dump command), a module with plain files declares *which* paths. The
holder composes every declaration on the node into one schedule. **Proposed.**
The data a module keeps in a database it gets from a provider is backed up by the provider, which
dumps every database it serves — so a consumer declares nothing, and a new consumer is covered the
day it is provisioned.
## Full or incremental
- **Databases: a full logical dump every time** (`pg_dump -Fc`, MS SQL `BACKUP DATABASE`,
`mongodump`). A dump restores with the store's own tool into a database beside the live one —
issue 241's recovery without the throwaway server — and is consistent, which a copy of a live data
directory is not.
- **Everything goes into one deduplicating repository per node** (restic or borg). Each night is a
complete restore point, yet only changed chunks cost space: the object store's 183 GB is copied
once, then each night adds what changed. This removes the full-vs-delta trade-off rather than
choosing a side.
Considered for the object store alone: the object store's own versioning with a lifecycle rule.
Rejected as the only copy — it lives inside the store, and a removed bucket or data directory takes
its versions with it.
## Where
On the same machine, outside every data directory the mesh manages, on a second filesystem where the
machine has one (the control node does). Not off-site: the scope is mistakes. The repository is
encrypted anyway (both tools require it); its key is a mesh secret (ADR 0085), so a person can
restore without the holder.
## How often, how long
- **Nightly**, at a quiet hour, as a scheduled step (ADR 0053).
- **On demand before a risky act** — a migration, a retirement, an operator's experiment — through a
verb; the act's own tooling can call it.
- **Kept: 14 daily, 8 weekly, 6 monthly.** A mistake is usually noticed within days, sometimes weeks
(a deleted file nobody opens). Six months bounds the space a slowly-noticed mistake needs.
Estimated cost on the control node: ~200 GB for the first night, a few GB a night after; well within
the second filesystem's 2.9 TB.
## Who runs it
A node seat, **`node-backup`**, held on every machine that has data by one module (named for the tool
it wraps). It receives the declarations, runs them, keeps the repository, and offers the verbs a
person needs:
- what is backed up here, and the last good night of each;
- take a backup now;
- restore one item **beside** the live one — a database to `<name>_restore`, a path to
`<path>.restored-<date>` — never over it. Swapping it in stays a person's act, as in issue 241.
## How it is proven
- Every run checks its own result; a failed or skipped night goes to the operator's output channel
(research 028), not only a log.
- Weekly: the repository's integrity check, and one database restored from the newest dump into a
throwaway instance and counted against the live one.
- A machine with data and no successful backup in 48 hours is a problem the mesh's status shows.
## Open questions
- restic or borg — both fit; restic is a single binary with no server, which suits a module.
- Whether the workstation and laptop take part at all, or only once a module there declares data.
- The mail spool is files and the forge has a dump command of its own; whether the forge's
repositories are worth backing up at all when every clone is a copy (issue 241 says the forge's
*database* is the part with no other copy).
@@ -0,0 +1,66 @@
---
status: graduated
initiated: 2026-10-06
touches:
- 00-META/how-we-build.md
- 01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md
- 01-RESEARCH/028-the-meshs-output-channel/00-overview.md
- 01-RESEARCH/019-a-warm-twin-of-the-running-mesh/00-overview.md
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
- 02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md
- 02-DECISIONS/0218-a-plan-sends-grants-before-code-rolls-out-one-machine-first-and-a-newer-merge-takes-over-an-older-plan.md
- 02-DECISIONS/0224-a-provider-that-keeps-failing-a-consumer-is-a-problem-the-controller-reports.md
- 03-DESIGN/01-to-be/06-the-controller.md
- 03-DESIGN/01-to-be/09-the-node-lifecycle.md
- 03-DESIGN/01-to-be/25-the-bus-on-nats.md
- 03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md
- 04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md
became:
- 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
---
# 031 — A core that cannot fail silently
**What.** The principles the mesh's core must hold — the controller, the machine host, the bus, the
console and the path a change takes through them — so that it is fully diagnosable, monitors itself,
heals what it knows how to heal, and upgrades itself without a person standing by. And the mechanisms
and the order in which to build them.
**Why.** The operator, 2026-10-06: *"I still notice a lot of race issues, and commands being ignored, or
no feedback, no logs, no monitoring. Our mesh core setup must be fully diagnosable, with active
monitoring, self-healing, self-upgradeable, self-monitoring. The core principles must be very sturdy, no
ambiguities, clear plan of execution, fail-proof setup."*
The record bears it out. In the six days to 2026-10-06, 92 issue reports were opened. Of the 48 read here
as core failures, **every one was noticed because a person or an agent looked**, and **none was raised by
the mesh unasked**. Four faults came back through a different door after their first fix, because each
fix closed an instance and left its class open. One merge was skipped by the bus, and twenty-three over three
days have no matching action; a provider failed for twenty-three hours with only its own
journal saying so; seven databases were dropped on one unreadable file.
**What it touches.** The controller (its verbs, `status`, plans, a lease), the host (its apply and
report), the bus (its advisories and its upgrade), the console, the build path, the output channel of
research 028, and the self-healing intent of research 017, which this effort extends from the loops that
converge modules to the core that runs those loops. 017 deferred heartbeats, conditions and advisories
until the bus was NATS; it is now.
**Documents.**
- [01 — The evidence](01-evidence.md): 48 issues classified by class of failure (races, dropped
commands, no feedback, logs only, two writers, manual repair, self-upgrade, CI-versus-live, third
party), with time to detect and how each was noticed.
- [02 — Principles](02-principles.md): nine, each with what exists, what is missing and **how it is
checked**; and the candidates weighed and not kept.
- [03 — Mechanisms and roadmap](03-mechanisms-and-roadmap.md): conditions, watchdogs from a signals table,
a self-check (`doctor`), the output channel, healers with a hand-act log, a controller lease and report
sequences, staged core upgrades with rollback, a facts snapshot for merge checks, a lab replay of every
incident; five phases, ordered by risk removed; the five largest risks today.
**Graduated 2026-10-06.** The operator approved the conclusion the same day (*"do the research and
implement it"*). The nine principles and the plan became
[ADR 0227](../../02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md);
the mechanisms, the tables and the six phases became
[to-be 45](../../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md). The two measurements owed
— the signals table's bounds and a week of the hand-act log — are Phase 0's work there, not
preconditions of the decision.
@@ -0,0 +1,202 @@
# 01 — The evidence, classified by class of failure
Every issue report opened between 2026-09-30 and 2026-10-06 that bears on the mesh's core — the
controller, the machine host, the bus, the console, the build path — read in full and classified by
**the class of failure**, not by the component it was found in. A component view says "fix the host";
a class view says "the same thing is wrong in four places", which is what a principle is for.
## The count
| | |
|---|---|
| issue reports opened 2026-10-01 to 2026-10-06 | **92** (about fifteen a day) |
| of those (and a few from the days before), classified below as core failures | **48** distinct issues |
| classified in more than one class | 17 of 48 |
| a fault that **came back** after a fix of the same symptom | 4 chains: 200 → 265, 230 → 264, 257 → 261 → 267, 175 → 184 → 248 |
| noticed because a person or an agent looked — at a stalled plan, a wrong outcome, a journal, a test run by hand, a review | **48 of 48** |
| of those, the mesh's own answer carried the fact for whoever asked (a refusal, a `status` line, a push's output) | 4 (233, 244, 259, 263) |
| raised by the mesh to anyone, unasked | **0 of 48** |
The recurrences matter most. Each fix was correct for its instance and left the class standing, so the
same symptom came back through a different door days later. That is the measurement behind the
operator's mandate: point fixes are converging on the instances, not on the class.
## The classes
Nine classes, as the mandate frames them. The table under each is the evidence; *detected* is the time
from the fault's start to the moment anyone knew; *noticed by* is how.
### (a) Races: concurrent actors without an ordering
Two actors act on the same thing, and the order of arrival — not an explicit order — decides the outcome.
| Issue | The two actors | Detected | Noticed by |
|---|---|---|---|
| 204 | an outgoing and an incoming controller both sent declarations | 2 min | a person saw a module undone |
| 201 | a plan's push carried a controller digest older than its successor had written | 10 min crash loop | a person, nothing answered |
| 214 | the controller rebuilding itself; the outcome reached the old one or neither | 27 min | a person asked the plan twice |
| 219 | an older build finishing later replaced a newer one | hours | a person reading builds |
| 234 | seven declarations arrived during an eleven-minute apply; the newest was composed from a stale view and undeclared four modules | 8 min of removals | a person, the modules were gone |
| 254 | three plans for three merges ran at once, each asking the same builds | hours | a person, one plan stuck "building" |
| 256 | a first machine's report landed between a module's two sends and read as stale | 7 min | a person |
| 257, 261 | the machine's five-minute reconcile and a delivery took the apply lock in the wrong order | 5 min (257), 29 s visible undo (261) | a person |
| 267 | a reconcile's report queued behind a delivery's apply overtook it at the controller | until a hand push | a person |
| 265 | a push reloaded the bus's permissions while its own answer was still owed | 54 of 103 pushes over two days | a person, "did not answer in time" |
**What they share.** Every one is a receiver that kept *the last thing written* rather than *the
newest thing by an explicit order*. Declarations carry a sequence (issue 107); **reports do not**
(267 says so: "the report does not carry the declaration's sequence number, so the digest decides").
Plans are ordered by when they were made only since ADR 0218. Builds are ordered since issue 219.
Controllers have no epoch, so two instances can both act (204). Ordering was added one message kind at
a time, each after a race in it was seen.
### (b) Commands silently ignored, arguments dropped
| Issue | What was dropped | Effect |
|---|---|---|
| 244 | the console removed `node` from every mesh-seat verb's schema and call | `plan` could only refuse; **`push <one machine>` arrived empty and pushed every machine** |
| 259 | a named push's flush sent every machine a build a policy held back | a fault met on every machine at once, not one |
| 202 | a module whose setting was unset was *left out* of the machine | the resolver vanished from a declaration, no error |
| 188 | a refusal inside "who is on the network" dropped a machine | 40 min, every symptom pointed elsewhere |
| 231 | a misspelled placeholder written to a file as literal text | passes every check |
| 241 | an unreadable contributions file read as "nobody asks" | **seven databases dropped and recreated empty** |
| 255 | the journal verb read nothing and said "-- No entries --" | a refusal that reads as a quiet service |
| 246 | a runtime that answered late was treated as absent | the console said modules "run nowhere" |
**What they share.** A receiver that could not tell *nothing was asked* from *something was lost on the
way*, and chose a default. In 241 and 244 the default was the most destructive reading available.
### (c) Outcomes not fed back to the caller
| Issue | What the caller was told | What happened |
|---|---|---|
| 200, 265 | "did not answer in time" | the push ran; the answer was refused by the bus |
| 176 | the console's build tool neither waits nor registers | — |
| 229 | `plans` answers once in prose; nothing waits for a plan | an agent went round the mesh with `curl` |
| 230, 264 | a host stood aside for its successor and its report was cancelled | the plan waited for ever, reading `late: false` |
| 186 | the build machine dropped 26 of 43 asks; nothing counts asks against outcomes | inferred two hours later |
| 237 | `assign` answered "held" and "does not resolve for lack of it" in one breath | a person or agent would loop |
Since 2026-10-06 the controller answers within ten seconds and keeps every call's outcome under an id
(`calls`, issue 265). Read live the same night: **that log holds the last hundred calls in the
controller's memory**, so a controller restart — which every merge to the controller's own repository
causes — forgets every outcome it held. And `status`, a read-only verb, took **18 seconds** to answer,
twice in a row, so even the health question is answered only through the "still running, ask `calls`"
path.
### (d) Failures visible only as log lines
| Issue | Where it was said | For how long |
|---|---|---|
| 179 (recurred) | the identity provider's journal, every five seconds | **23 hours**, about 31 000 refused logins |
| 184 | the controller's log: slow consumer, heartbeats dropped | 24 min deaf |
| 187 | five faults in one day, each found by reading a container's log hours later | hours each |
| 183, 217, 265 | a `Permissions Violation` line from the bus client library | days |
| 233 | `status` said `refused`, correctly; nothing said it had lasted | 1.5 h |
| 243 | nothing: machines silently ignored lower licence generations | until a login waited three minutes |
| 248 | the controller's event loop stopped logging at 15:17 | hours; "status showed every plan done" |
| 266 | nothing: a merge was skipped by the bus | **23 unmatched merges over three days** |
| 238 | a ban list of 400 entries | the operator's own address banned for four weeks |
`status` printed its all-well sentence through 179, 248 and 266. ADR 0224 made the first of those break
it. The other two have no signal that `status` reads.
### (e) State that two writers own
| Issue | The two writers |
|---|---|
| 190, 222 | the runtime's configuration written by modules that are not the runtime, and by the controller |
| 201 | the controller seat's row written by a successor, read by a predecessor pushed back in |
| 239 | two definitions, in two repositories, held one module name |
| 245 | "behind" answered by a commit comparison beside the plan that already knows |
| 257, 261, 267 | the machine's state written by both the delivery and the five-minute reconcile |
| 250 | a merge announced by the forge's tool and by its poll |
| 179 | the identity provider's admin password: the mesh minted one, the database kept another |
The operator's direction on 245 is the principle in their own words: *"a second answer to the same
question is how the two came to disagree."*
### (f) Manual repair needed
Counted from the reports' own "what unblocked it" sections:
| Repair by hand | Issues | Times |
|---|---|---|
| a push by hand to unstick a plan waiting on a report | 230, 257, 264, 267 | at least 4 |
| a controller restart to recreate a missing object or let go of a held message | 208, 248 | 2 |
| a one-off program run as the controller, outside the service | 201, 248 | 2 |
| the identity provider's admin reset through its bootstrap command | 179 | 2 |
| a kept file restored on a machine by hand | 233 | 1 |
| a stuck plan closed by hand | 214, 254 | 2 |
| a ban lifted by hand | 238 | 1 |
| a consumer remade from now | 248 | 1 |
ADR 0224 (*detected automatically, repaired where safe, loud where not*) is the first rule that turns
one of these into a mechanism. 248's `broker consumer-reset` and 254's `plans close` turned two into
verbs a person runs. Every other row is still a hand on a machine.
### (g) Self-upgrade fragility
The core updates itself: the controller rebuilds and replaces itself, the host delivers its own
successor (ADR 0141), the runtime and the console are modules, and the bus is a module on the control
node.
| Issue | What the self-upgrade broke |
|---|---|
| 201 | the controller pushed back to an older build than its own successor's row |
| 204 | two controllers both sending during a handover |
| 213, 223 | the controller ran as a container, and a new mesh installed it so |
| 214 | the plan that rebuilds the controller lost track of it |
| 230, 264 | the host that stands aside loses the report of the apply that delivered it (fixed twice) |
| 245 | a rebuild of everything replaced the bus's container: **every runtime lost the bus for a minute** |
| 248, 266 | a controller restart is where merges go missing: most of 266's 23 lie in such windows |
| 217 | a refused announcement crash-looped nine runtimes, closing the path that would merge the fix |
A machine's first-in-line rollout (ADR 0218) protects modules. It does not protect the core from itself:
the health a plan waits for is "reported applied", which a controller that cannot plan, a host that
cannot report or a bus that drops messages can each satisfy. Nothing rolls back. The recovery in 201
was the mesh's own binary run by hand from the newer image.
### (h) Checks that pass in CI and fail live
| Issue | The environmental fact the check did not have |
|---|---|
| 177 | the store-backed tests are skipped by the quick check, and the mesh runs none of a module's tests |
| 236 | the host's declaration validation is not run by the catalogue check |
| 262 | musl takes an NXDOMAIN for IPv6 as final; glibc does not |
| 263 | the real machine names make a consumer's identity 23–26 characters against a bound of 20 |
| 202 | the controller's test against the real catalogue, run by nobody until that day |
| 228 | the host's removal has no case for a `user` — found by a review, not a test |
Each check was right about the world it was given. None of them was given the mesh's world: its machine
names, its catalogue, its host's validation, its C libraries.
### (i) Third-party bugs
| Issue | |
|---|---|
| 266 | the bus server's 2.10 release skips messages on a consumer with several filter subjects |
| 265 | a reload of the bus's authorization forgets every reply permission already granted (documented server behaviour, read from its source) |
| 262 | a resolver answering NXDOMAIN where NODATA is correct, met by musl's stricter reading |
The lesson of 266 is not "upgrade the bus" — it is that nothing compared *what was announced* with
*what was acted on*, so a dependency's bug was silent for three days. A defence in depth (watch the
outcome, not the transport) would have caught it whichever layer was wrong.
## What would have prevented or caught each class
| Class | Would have been prevented by | Would have been caught by |
|---|---|---|
| (a) races | every message ordered by its writer, stale refused by every receiver | a lab replay of the interleaving |
| (b) dropped | refusing an unknown or unreadable input by name | a schema walk over every verb |
| (c) no feedback | answer at once with an id; outcome kept durably | a watchdog on "asked and never finished" |
| (d) logs only | — | a condition in `status` and a notification |
| (e) two writers | one writer per piece of state | a registry of writers checked at composition |
| (f) manual repair | a healer for every repair done twice | a counter of hand acts |
| (g) self-upgrade | one machine first, health-gated, rolled back | a lab upgrade with a deliberately broken build |
| (h) CI vs live | checks fed the real mesh's facts | the same, before merge |
| (i) third party | pinning and testing the version that runs | an end-to-end count of announced vs acted |
The two columns are the principles of [02](02-principles.md). Read by count, **the "caught by" column
is the cheapest and widest**: a watchdog and a condition would have shortened most of the 48 from "a
person noticed" to minutes, whatever the cause.
@@ -0,0 +1,247 @@
# 02 — Principles for the core, each with how it is checked
Nine principles. Each is stated as a rule a reviewer can refuse a change against, carries the classes of
[01](01-evidence.md) it answers, says what exists already, and says **how it is checked** — the
repository's own rule ([how-we-build §5](../../00-META/how-we-build.md)): a rule that states no check is
indistinguishable from a wrong one.
They extend, not replace, the six of [research 017](../017-a-mesh-that-heals-itself/01-the-intended-behaviour.md)
(a loop compares with what is; healing is the ordinary path again; a repair never destroys; nothing fails
silently; what cannot be fixed goes to an agent; correctness, not only liveness). Those are about the
loops that converge modules. These are about **the core that runs those loops**: the controller, the
host, the bus, the console and the path a change takes through them. 017 deferred heartbeats, conditions
and advisories until the bus was NATS. It is now, so that deferral has expired.
**The core**, for this document: the controller, the machine host and its launcher, the bus server, the
tool runtime and the console, the build seat, and the forge's announcer of merges. Everything a change
passes through before a module's own code runs.
---
## P1 — One writer per piece of state
Every piece of state the mesh keeps has exactly one writer, named. Anyone else who wants it changed asks
that writer; nobody writes beside it, and nobody computes a second answer to a question it already
answers.
- **Answers:** (e), most of (a). Issues 190, 201, 204, 222, 239, 245, 250, 257/261/267.
- **Exists:** the controller is the only writer of stream definitions (to-be 25); ADR 0222 §3 (the
controller writes no file a seat's holder owns); the collision check at composition (no two modules
declare one path, unit, name or package); the operator's direction on 245.
- **Missing:** a single writer for a *machine's applied state* (the delivery and the five-minute
reconcile both apply and both report — three issues in two days); a single *controller* (two instances
can both act during a handover, 204: nothing holds a lease); a single announcer per event kind (250
was found by counting duplicates by hand).
- **How it is checked:**
1. A **writers table** — state kind, its writer, where it is kept — is part of the to-be design, and a
test in each core repository asserts that the code paths that write each kind are the one named
(by a lint over the store's write calls and the bus subjects each component publishes on, the
latter read from the grants the controller composes: a subject two components may publish on is
refused at composition unless the table says it is shared).
2. **Live:** the controller holds a **lease** (a key-value entry with a revision) and every message it
sends carries the lease's epoch; a host refuses a declaration from an older epoch and says so. A
probe ([03](03-mechanisms-and-roadmap.md), the self-check) asserts one lease holder and no message
from a stale epoch in the last interval.
## P2 — Everything that changes state carries its writer's order, and every receiver refuses what is older
Declarations, reports, plans, builds, calls and announcements each carry `(writer, epoch, sequence)`.
Every receiver keeps the highest it has accepted per writer, and **refuses** — with a line in the mesh's
own words and a counter — anything older. Arrival order never decides.
- **Answers:** (a). Issues 201, 204, 214, 219, 234, 256, 257, 261, 264, 267.
- **Exists:** a declaration carries a sequence and a host keeps the newest (issue 107, to-be 25 §3); a
newer build wins over an older one finishing later (219); a newer merge's plan supersedes an older
one (ADR 0218 §3); a report about a declaration the mesh has moved past no longer replaces the stored
account (267).
- **Missing:** a **report carries no sequence** — the digest last recorded as sent decides, which is why
each of 256, 257 and 267 needed its own rule. No epoch on the controller. A plan's state carries no
revision, so two instances can both advance it (214).
- **How it is checked:**
1. A contract test per message kind, in the receiver's repository: deliver `n`, then `n−1`; the state
names `n` and a refusal is counted. Deliver from epoch `e−1` after `e`: refused. A new message kind
without such a test fails a check that lists every subject the component consumes against the
tests that name it.
2. **Live:** the refusals counter is a signal (P5): zero is normal, a burst is a condition naming the
writer that sent stale.
## P3 — Every command is acknowledged at once, and its outcome is kept where it can be read later
A call is answered within a bound the caller can rely on — with its result, or with an id. Its outcome is
kept **durably**, outlives the process that ran it, and can be read by id or waited on. Nothing is fired
and forgotten, and no answer depends on what the command does to the transport carrying it.
- **Answers:** (c). Issues 176, 186, 200, 229, 230, 237, 264, 265.
- **Exists:** since 265, a seat's call answers within ten seconds or says "running" with an id, `push`
answers before it acts, and `calls` keeps the last hundred calls and their answers; the build path
says "asked, not waited for" and ADR 0219 §3 makes every cancel/kill leave an outcome.
- **Missing:** `calls` lives in the controller's memory, so **a controller restart forgets every
outcome** — and the controller restarts on every merge to its own repository. Nothing waits on a plan
(229). Observed the night this effort began: the read-only `status` took 18 s, so the health question
itself is answered through the "still running" path.
- **How it is checked:**
1. A test that walks every verb the controller announces: each answers within `AnswerWithin`, with a
result or an id (already partly built for 265).
2. A test that restarts the controller between a call and the read of its outcome: the outcome is
still there.
3. **Live:** a probe calls `status` and asserts it answers *in full* within the bound; a call
`running` for longer than its verb's declared bound is a condition (P5).
## P4 — Nothing is dropped silently: an input that is unknown, unreadable or unmet is refused by name
A receiver that cannot read, place or honour an input refuses it and says which, where and why. It never
substitutes a default — above all never "empty" — for "I could not tell". An unknown argument, key,
placeholder, seat or subject is refused, naming it.
- **Answers:** (b). Issues 188, 202, 231, 241, 244, 246, 255, 259.
- **Exists:** the host's parser refuses unknown keys (ADR 0007, 0045); an undeclared setting or endpoint
is refused (ADR 0164, 0138); a verb takes only its declared arguments (ADR 0154) and, since 244, the
controller and the console refuse an undeclared one by name; a seat the mesh does not answer is
refused by name (ADR 0222 §1); ADR 0219 §3, "nothing dropped is silent".
- **Missing:** the rule is in a dozen records and in no principle, so each new reader re-meets it. The
destructive form — **an unreadable input read as "nothing asked", then acted on** (241) — has no
general guard.
- **How it is checked:**
1. Per component, a test that feeds each input reader an unreadable, malformed and foreign input and
asserts a refusal, never an empty result. A reader whose error path returns an empty value fails a
lint that the core repositories run (the shape is mechanical: an error branch that returns the
zero value of a collection).
2. Schema walks for every verb (244's tests) and every placeholder namespace (231).
3. **Destructive deltas are braked**: a reconcile that would withdraw more than a bound of what it
holds (one consumer, one module, a fraction set per provider) stops and raises a condition instead
(P7). Checked by a test that empties the input and asserts nothing is withdrawn.
## P5 — Every expected signal has a watchdog: absence is itself a condition
Every signal the core expects on a cadence or after an act — a heartbeat, a report after a send, a
plan's progress, a build's outcome after its ask, the controller's event loop taking something in, a
provider's standing, an announcement turning into an action — has a declared bound. Silence past the
bound is raised as a condition, naming what was expected, from whom, since when.
- **Answers:** (c), (d), (i). Issues 179, 184, 186, 187, 230, 243, 248, 257, 264, 266, 267.
- **Exists:** a machine's "last heard from — out of touch N m" in `node show`; ADR 0090's stuck machine
(three identical reports); ADR 0224's provider standing, shown with its silence after thirty minutes;
266's catch-up of merges not acted on after ten minutes (the first true *announced-versus-acted*
watchdog); ADR 0162's bound on a plan, which 230 found never fires (`late: false` for ever).
- **Missing:** a list of the signals, their bounds and their owners; the plan bound working; the event
loop's last-taken age (187); asks counted against outcomes (186); the bus's own advisories (slow
consumer, maximum deliveries, permission violations) read as observations instead of log lines.
- **How it is checked:**
1. A **signals table** — signal, emitter, cadence or trigger, bound, condition raised — kept in the
to-be design, and compiled into the controller. A test generated from it suppresses each signal in
turn and asserts the named condition is raised within its bound and cleared when the signal
returns.
2. **Live:** the self-check (P6) reports, for every row, the age of the newest signal, so a row that
never fires is itself visible.
## P6 — The mesh checks itself continuously, against live facts, and says what it found outward
The invariants the design states are probed **against the running mesh** on a schedule, not only in unit
tests. A violation is a **condition** — durable, with since-when, evidence and who can resolve it —
shown in `status` and **sent to the operator** through the output channel. The checker's own heartbeat is
watched from somewhere it does not run.
- **Answers:** (d), (h). Issues 177, 187, 238, 245, 253, 262.
- **Exists:** `status` itself (assembled from reports, as how-we-build §5 requires); 017's *condition*
shape; 028's output seat, researched only; individual live checks done by hand (262: "the live check
stays by hand, on each resolver"); 253's measurement before the collector's first run — the one time
in the window a check ran before the damage.
- **Missing:** a scheduled runner, a condition store, a channel out, and a watcher's watcher.
- **How it is checked:**
1. Every invariant in the to-be design that names a live check is a **probe** in the self-check's
registry; a check over the design documents counts invariants with a stated live probe against
those without, and the number may only go down.
2. The self-check publishes a heartbeat; a second machine's watcher raises "the self-check is silent"
through a channel that does not depend on the control node.
3. **Live, once:** a deliberately broken invariant on a lab mesh appears in `status` and as a
notification within one probe interval.
## P7 — A known failure heals itself, under a brake, and every repair is said
A failure that has been repaired by hand twice is a failure the mesh must repair itself: by running the
ordinary path again (017 P2), never by destroying (017 P3), with a budget and a back-off, and with one
line and one event saying what it did and why. When the budget is spent, or the only repair destroys,
it is a condition and a notification, not a retry.
- **Answers:** (f). Issues 179, 208, 214, 230, 233, 248, 254, 257, 264, 267.
- **Exists:** ADR 0224 §5, *detected automatically, repaired where safe, loud where not*, applied to the
identity provider's admin; the host's launcher rolls back once to known-good and then halts (ADR
0141); the provisioner's `holds` re-provisions what a backend lost (017/02); `broker consumer-reset`
and `plans close` as verbs.
- **Missing:** the general mechanism. The most frequent hand act in the window — **a push by hand to
unstick a plan waiting on a report** — has no healer: the controller could ask the machine to report
again (the machine knows what it applied) before waiting longer.
- **How it is checked:**
1. Every hand act on the core is done through a verb that records it (who, what, why) — the **hand-act
log**. Its count per week is a reported number; an act recorded twice for the same cause is a
condition asking for a healer.
2. Each healer ships with a test that induces its failure, asserts the repair and the event, and
asserts the brake after the budget.
## P8 — The core upgrades itself one machine at a time, health-gated, and rolls back on its own
A new controller, host, runtime or bus reaches one machine first; it is **healthy** only when the
self-check's probes for that component pass there (not merely when it "reported applied"); the rest
follow only then. A component that does not become healthy within its bound is rolled back to the last
known good **by something other than itself**, and the rollback is said. The component being replaced is
never the only witness of its successor's success.
- **Answers:** (g). Issues 201, 204, 213, 214, 217, 230, 245, 248, 264, 266.
- **Exists:** ADR 0218's one machine first, for modules, with "applied and current" as the gate; ADR
0141/0142's side-by-side host versions, known-good and the launcher's single rollback; ADR 0185's
controller serving what it can when it is behind its seat's row; 264's report kept on disk across the
hand-over.
- **Missing:** a health definition per core component; a gate stronger than "reported"; rollback for
the controller, the runtime and the bus; a lease hand-over between controllers (P1); a planned,
rehearsed path for the bus, which is still one process on one machine and whose next upgrade (266:
2.10 → 2.11) is one-way.
- **How it is checked:**
1. **Lab:** a deliberately broken build of each core component (one that starts and does nothing; one
that crashes; one that cannot reach the bus) is merged on a lab mesh. Each is rolled back without a
hand, the mesh ends on the previous build, and a condition and a notification say so.
2. **Live:** every core rollout leaves a record — first machine, health verdict, time to verdict,
rolled back or not — readable through `plans`.
## P9 — A check is fed the real mesh's facts before a change is merged
A check whose verdict depends on the environment — names and their lengths, the machines that exist,
the catalogue as it is, the host's validation, the C library, the server versions — runs against **the
mesh's real facts**, exported and anonymised, before merge. A dependency's version that the mesh runs is
the version its tests run.
- **Answers:** (h), (i). Issues 177, 202, 228, 236, 262, 263, 266.
- **Exists:** 266's `TestTheImageIsTheServerTestedHere` (the bus image's release equals the tested
server's); ADR 0223's composition test that renders the resolver's machine list; 202's test against the real
catalogue (run by hand).
- **Missing:** the export of facts; a merge gate that composes every real machine's declaration with the
change and runs the host's validation over it (which would have refused 236, 263 and 202 in their own
pull requests); a resolver test under musl as well as glibc.
- **How it is checked:**
1. The controller exports a **facts snapshot** (machines, names, assignments, seats, catalogue
commit — no secrets, no addresses) daily; the core repositories' merge check composes every machine
from it with the change applied and runs the host's validation; a pull request that makes any
machine fail to compose or validate fails its check, naming the machine's role and the module.
2. The snapshot's age is a signal (P5).
---
## Candidates weighed and not kept as principles
- **"Every invariant has a live probe, not only a unit test"** — merged into P6; it is how P6 is built.
- **"Environment-dependent checks run against the real mesh's facts"** — kept as P9; the third-party
case (i) folded into it, because pinning and testing the version that runs is the same act.
- **"Self-healing is the default"** — kept as P7 but narrowed to *known* failures, those repaired by
hand twice. A default of healing everything heals what is not understood, which is how a repair
destroys (241's reconcile was, in its own terms, healing).
- **"No loop blocks on long work"** (184, 248, 175) — not a separate principle: a blocked loop is a
signal gone silent (P5, the loop's last-taken age) and a design defect each owner fixes; stating it
as a principle adds a rule with no mesh-wide check.
- **"Clear plan of execution"** from the mandate — not a principle about the mesh; it is the roadmap in
[03](03-mechanisms-and-roadmap.md), and each phase there states its own verification.
## How the principles relate
P2 and P1 **prevent** the races. P4 **prevents** the silent drops. P3, P5 and P6 **catch** whatever the
first three miss, at the cost of minutes, not hours. P7 and P8 **repair**. P9 **moves** the catching
before merge. The order of the roadmap follows from that: catching first, because it is cheapest and
covers every class, including the ones nobody has met yet.
@@ -0,0 +1,221 @@
# 03 — Mechanisms and a phased roadmap
The principles of [02](02-principles.md) need few new things. Most of the parts exist in some form;
what is missing is the connective tissue that makes a fact the mesh already has reach someone without
being asked. This document names the mechanisms, then orders them into phases **by risk removed per unit
of effort**, each phase with deliverables and a verification that says it is done.
Effort is given in **focused working days** of one agent-and-operator pair, at the pace the record shows
(a located issue to a merged fix in under a day is common). The figures are for ordering, not promises.
## The mechanisms
### M1 — Conditions (P5, P6)
017's *condition*, built: a durable fact about something the mesh owns — what is wrong, since when, the
evidence, what was tried, who can resolve it, and whether it may clear itself. Kept in a key-value
bucket the controller writes (one writer, P1), keyed by subject (`plan/<id>`, `machine/<role>`,
`provider/<module>/<consumer>`, `core/<component>`). Raised and cleared by observation only; a person
can **silence** one for a stated time, never resolve it. `status` becomes, first, the list of open
conditions; the all-well sentence is "no open conditions". ADR 0224's provider standing is the first
condition kind and moves into it unchanged.
### M2 — Watchdogs from a signals table (P5)
One table, compiled into the controller, of every signal the core expects. Its first rows, each from an
issue in [01](01-evidence.md):
| Signal | Bound (to be measured, then set) | Condition raised | Issue |
|---|---|---|---|
| machine heartbeat | 3 × interval | machine silent (asleep is a declared state, ADR 0211) | 187 |
| report after a send | the machine's last apply duration × 3, at least 2 min | sent, not reported — then **ask the machine to report again** (M5) | 230, 257, 264, 267 |
| plan tier progress | per tier, from build and apply durations | plan stalled at tier N, waiting on X | 214, 230, 254 |
| controller event loop took something | 2 min while the stream has pending | controller deaf | 184, 248 |
| merge announced → plan made or "nothing reads it" | 10 min (exists, 266) | merge never acted on | 248, 266 |
| build asked → outcome | build's own declared timeout | ask lost | 186 |
| call `running` → finished | the verb's declared bound | call hung | 265 |
| provider standing repeated | 30 min (exists, ADR 0224) | provider silent | 179 |
| bus advisories: slow consumer, maximum deliveries, permission violation | any | bus refused or dropped X for Y | 183, 187, 217, 265 |
| self-check heartbeat | 2 × its interval, watched from a second machine | the watcher is silent | — |
| facts snapshot age | 2 days | merge checks run on stale facts | 263 |
The bus advisories are the cheapest row: the server already publishes them on its system subjects, and
the controller only has to subscribe (read-only) and translate each into the mesh's words, naming the
call or the consumer, as 265 now does for a refused reply.
### M3 — The self-check: `doctor` (P6)
A controller verb, `doctor`, and the same code run every few minutes by the controller itself. Each run
executes the **probe registry** — the live form of the design's invariants — and raises or clears
conditions. First probes, each an invariant that a person checked by hand in the window:
- every machine's declaration composes, and every host would accept it (236, 263);
- every holder of the mesh's resolver answers a machine name for IPv4 and NODATA for IPv6 (262);
- every seat on record has a live holder that answers (208, 218);
- every kept archive is held by a manifest (253 — the controller's collection command already reports it);
- exactly one controller holds the lease (P1);
- every durable consumer's position is near its stream's head (248's replay, 266's skip);
- no address the mesh owns is in a ban list (238);
- `status` answers in full within its bound (P3).
`doctor` with no argument answers the last run's verdict at once (P3) and, with `run`, runs now under an
id. Its own heartbeat is a signal (M2), watched from a second machine.
### M4 — The output channel (P6)
[Research 028](../028-the-meshs-output-channel/00-overview.md)'s seat, built minimally first: **one**
channel the operator chose (028 records it), plus the desktop notifier where the operator is. A condition
is sent when raised, once more if it lasts past a bound, and when it clears. Deduplicated by the
condition's key. The watcher's watcher (028's open question) is the second-machine watchdog of M3,
sending through a channel that does not pass through the control node.
### M5 — Healers (P7)
A healer is a registered response to one condition kind: its repair (the ordinary path again), its
budget, its brake, and the event it emits. First healers, all from hand acts in [01](01-evidence.md) §(f):
| Condition | Repair | Brake |
|---|---|---|
| sent, not reported | ask the machine to report what it last applied (it keeps it since 264); if that names another declaration, send again | twice, then condition |
| plan stalled on a superseded or finished wait | close the plan with its note (`plans close`, done by the mesh) | once |
| seat holder without its worker | raise the seat's objects again (208) | once per holder |
| consumer far behind on a history stream | `broker consumer-reset` (248) — **only** for the consumers the table marks resettable | once, then condition |
| provider admin refuses the mesh's secret | ADR 0224 §5 (exists) | exists |
And the **hand-act log**: every repair a person makes on the core goes through a verb that records who,
what and why. Its weekly count is the measure of P7.
### M6 — Order and epochs (P1, P2)
- The controller takes a **lease** in a key-value bucket before it acts and renews it; its revision is
the **epoch** every declaration and plan write carries. A starting controller waits for the lease;
the outgoing one stops sending when it loses it. That closes 204 and makes 201/214 detectable.
- A **report carries the sequence** of the declaration it is about; the controller keeps the highest
per machine and refuses older accounts by sequence, not by a digest lookup (256, 257, 267 become one
rule).
- **The host has one apply queue.** A delivery and the reconcile are two reasons to enqueue the same
act; the queue applies the newest declaration once, and makes one report (257/261/267 become
impossible rather than handled).
- Every receiver's refusal of something stale is a counted line (P2's live check).
### M7 — Staged, reversible core upgrades (P8)
- **A health definition per core component**, written as probes in M3's registry: the controller
answers `status` in bound and holds the lease; the host has reported its current declaration; the
runtime has announced and answers a PING; the bus has every stream and every durable consumer and
passes a request/reply round trip.
- **The gate:** ADR 0218's first machine is judged by those probes, not by "applied".
- **Rollback by a witness that is not the new build:** the host's launcher for the host (exists); for
the controller, the previous controller's process kept installed beside it, re-started by the host
when the new one does not take the lease in bound; for the runtime, the host's known-good the same
way. Each rollback is a condition, so it is said.
- **The bus is planned, not rolled.** A bus upgrade is a declared maintenance step: streams snapshotted,
the step announced as a condition while it runs, every consumer's position checked after (M3). The
one-way 2.10 → 2.11 upgrade of 266 is the first. Whether the bus should become a cluster of three so
that it can be upgraded live is a question for its own effort.
### M8 — The facts snapshot and the merge gate (P9)
The controller exports a facts snapshot (machines by role and name length, assignments, seats, catalogue
commit) to a place the build seat reads. The core repositories' and the catalogue's merge checks compose
every machine with the change and run the host's validation. The resolver module's tests run under musl
and glibc. A bus, store or library version the mesh runs is the one its tests run (266's pattern,
generalised).
### M9 — The lab replay (all)
[Research 019](../019-a-warm-twin-of-the-running-mesh/00-overview.md)'s warm twin, or a throwaway lab of
three containers, running **scripted replays of each incident** in the window: a reconcile due during a
push; a host self-update during its report; a bus reload during a call; a consumer with several filters
under mixed traffic; a missing consumer made with the server's default; an unreadable contributions
file; a controller rebuilding itself mid-plan; two controllers at once. Each replay asserts the
principle's outcome (refused stale, condition raised, healed, rolled back). They run on every merge to a
core repository.
---
## The roadmap
Ordered by **risk removed per day**. Detection comes first because it covers every class at once,
including the ones not met yet; prevention second; repair and staging third; the pre-merge and lab work
last because they are larger and pay off over months.
### Phase 0 — Finish what is in flight (2–3 days)
- Land and roll out the located fixes: 244, 264, 265, 266 (including the bus's planned 2.11 upgrade, done
as M7's first planned bus step), 267, 257/261.
- Make `calls` durable (a key-value bucket, bounded by count and age), and bring `status` inside its own
answer bound.
- Start the **hand-act log** now, before anything else, so every later phase is measured against a
baseline.
- **Done when:** the four located core issues resolve with their live checks; a controller restart
keeps `calls`; `status` answers in full within ten seconds.
### Phase 1 — The mesh says when it is wrong (5–8 days)
- M1 conditions (ADR 0224's standing moved into them); M2 watchdogs for the first eight rows; the bus
advisories subscribed and translated; M3 `doctor` with the first probes; M4 with one channel and the
second-machine watcher.
- **Done when:** on a lab mesh, suppressing each signal in the table raises its condition within its
bound and sends a notification; clearing it clears both. Live: a week of conditions read back, every
one either real or a bound corrected.
- **Risk removed:** every class in [01](01-evidence.md) moves from "noticed by a person, hours later" to
"said by the mesh, minutes later".
### Phase 2 — Order and one writer (5–7 days)
- M6: the controller's lease and epoch; a report's sequence; the host's single apply queue; stale
refusals counted.
- The writers table and the signals table written into a to-be design, with their compile-time checks.
- P4's lint for empty-on-error readers in the core repositories, and the destructive-delta brake in the
provisioner harness.
- **Done when:** the lab replays of 204, 257/261/267 and 241 end with "refused stale", "one report" and
"withdrawal braked" respectively; the contract test for every consumed subject exists.
- **Risk removed:** class (a), the largest by count, and the destructive half of (b).
### Phase 3 — Healers (3–5 days)
- M5's first healers and the rule that a repair done by hand twice asks for one (from the hand-act log).
- **Done when:** a lab mesh recovers from each induced failure in the healers table with no hand, says so,
and brakes after its budget. Live: a week in which the hand-act log has no repeat.
### Phase 4 — Core upgrades that roll back (8–12 days)
- M7: health definitions; the gate; rollback for the controller and the runtime; the bus as a planned
step.
- **Done when:** on a lab mesh, a deliberately broken build of the controller, the host and the runtime
is each rolled back without a hand, the mesh ends on the previous build, and the rollback is a
condition and a notification. Live: the next three core rollouts each record a health verdict.
- **Risk removed:** class (g) — the failures that take the control path itself down.
### Phase 5 — Checks before merge, and the replay suite (8–12 days, then ongoing)
- M8: the facts snapshot and the compose-and-validate merge gate; the libc matrix; versions tested as
run.
- M9: every incident in the window as a scripted replay, run on every core merge; each new core issue
adds its replay as its "how it is checked".
- **Done when:** the replays of 236, 262, 263 and 266 fail on the commit before their fix and pass after;
a new core issue cannot resolve without a replay or a stated reason why none is possible.
**Total, roughly six to eight weeks of focused work**, with the first visible change — the mesh saying
when it is wrong — inside the first two.
## The five largest risks today
Ranked by likelihood × damage, from the window's evidence and the mesh as read the night this effort
began:
1. **A stall nobody is told about.** A plan, a merge or a report that stops is noticed only by someone
looking (230, 248, 257, 264, 266, 267; issue 187 still open). Every release goes through a plan.
2. **A destructive act on a misread input.** 241 dropped seven databases on one unreadable file; 234
undeclared four modules from a stale composition; 245's advice rebuilt everything and cut the bus;
253's collector would have deleted every archive. There is no general brake on a large withdrawal.
3. **The core replacing itself with nothing to roll it back.** A controller build that starts but cannot
plan (201, 214) halts every later change, because the controller is what plans the fix. Only the
host has a launcher rollback.
4. **The bus as a single, un-upgradeable process.** It runs a release that skips messages (266), its
reload drops owed replies (265), replacing it cuts every machine off (245), and its next upgrade is
one-way and needs a restart.
5. **Concurrent actors with no lease or report order.** Several agent sessions, plans and reconciles act
at once; on the night this began, four named pushes, one per machine, started within two seconds. The digests
catch most of it now, one rule per message kind; the next message kind will not have one.
@@ -0,0 +1,85 @@
---
status: graduated
initiated: 2026-10-06
became: [02-DECISIONS/0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md, 03-DESIGN/01-to-be/48-a-module-says-how-it-is-healthy.md]
touches:
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
- 03-DESIGN/01-to-be/18-building-a-module.md
- the module manifest
- the node-engine
- the release gate
---
# 032 — A module says how it is healthy
## What is investigated
Whether, and how, every module should declare **how its own health is checked**, as a field of its
manifest, so the mesh can judge a module the way it already judges its core parts.
Today the release gate ([ADR 0236](../../02-DECISIONS/0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md)) judges a
module on its first machine by what the mesh can see from outside: the declaration applied, no new
condition about the module or its machine since the send, and its tools answering. It does not see
whether the module's own service works. A container that applied cleanly and then restarts in a loop,
a web application whose port is open but whose pages fail, a database that accepts connections and
refuses queries — each passes the gate and is caught only through what it breaks later, if anything
notices at all. To-be 45 names this gap ("no container state in the gate").
The questions:
1. **What a health declaration says.** The kinds of check a module may declare (a container's own
health state, an HTTP request and the answer expected, a TCP connect, a command run inside the
service, a query, a tool of the module's own that answers "healthy"), its interval, its timeout,
how many failures in a row count, and a start period during which failure does not count.
2. **Who runs the checks, and where the result goes.** The node-engine on the machine, the node tools,
or the module itself; how the result reaches the controller (the report, an event, a seat verb); and
what the release gate, the self-check and the healers do with it.
3. **What the mesh already has to build on.** Container runtimes' own healthchecks, which many images
ship and which the mesh neither reads nor sets today; systemd's own state for services; the tools a
module already serves.
4. **What "healthy" covers.** Liveness (it runs), readiness (it serves), and whether a module's health
may depend on what it requires (a provider down makes its consumers unhealthy — said once, at the
provider, not once per consumer).
5. **The cost and the noise.** How often checks may run across all modules on a small machine, and how
a check avoids the single-sample flaw the self-check already met (issue 277).
6. **Migration.** How every catalogue module gets a declaration, what a module without one is judged
by, and whether `module check` should require one.
## Why
The mesh now rolls a module out on its own, one machine first, and puts the previous build back when
the first machine is not healthy. That promise is only as good as "healthy" is, and for a module it is
today judged from the outside. Making each module say how its health is checked turns "applied" into
"working", for the gate, for the self-check and for whoever asks.
## What it touches
The module manifest and `module check`; the node-engine, which would run or read the checks; the
release gate and the doctor probes of to-be 45; the healers, which may restart what stays unhealthy;
the operator's conversation (ADR 0234), which carries what stays unhealthy; and every catalogue module,
each of which would gain a declaration.
## Where it stands
Evidence gathered on the live mesh, options weighed and a decision drafted, 2026-10-07:
- [01 — The evidence](01-evidence.md): 125 catalogue modules, 68 of them running something long-lived
(49 a container, 19 a service); no manifest can declare a check and nothing reads one; 19 of 73
long-running catalogue containers carry an image check, two of which were wrong in the mesh's hands;
every restart count is 0 because a recreate loses it, and the runtime's event history is about a minute
long. Of seven recent incidents, liveness alone would have caught the crash loop, readiness the
eleven-hour silent web app, and only a module's own tool the refused identity-provider admin.
- [02 — Options](02-options.md): who runs the checks, where the result goes, liveness and readiness,
dependency-aware health, the field's shape and the migration.
- [03 — Recommendation](03-recommendation.md): liveness judged for every long-running resource at once;
readiness declared per resource in a field named `health` and run by the node-engine; the state in the
report, the condition raised by the controller on the second look, so the gate needs no new rule; a
provider down said once at the provider; a proposed decision text with how each rule is checked, and
the migration of the 68 modules.
**Graduated 2026-10-07** on the operator's word (*"yes, turn it into a decision"*), as
[ADR 0240](../../02-DECISIONS/0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md) and
[to-be 48](../../03-DESIGN/01-to-be/48-a-module-says-how-it-is-healthy.md). The record takes the proposed
decision, adding: a fifth state, `held`, for a resource under a maintenance step; a ceiling of five minutes on
grace plus failing looks, so a broken start is said inside the gate's bound; and the gate's *own health* reading
the stated health, so a resource still starting is not yet a pass.
@@ -0,0 +1,196 @@
# 01 — The evidence
Measured on 2026-10-07 on the live mesh of four machines (a home server, a control node, a workstation
and a laptop), read only: the three catalogue repositories at their trunk, the controller's and the
node-engine's source at their trunk, each machine's container runtime and service manager, and the
controller's own answers. Counts, not anecdotes. Machines are named by role.
## 1. What the catalogue runs
The three catalogue repositories hold **125 module manifests**: 113 in the main catalogue, 12 in the
media catalogue. The photos repository holds application code and no manifest; its two instances are
modules in the main catalogue.
Each manifest sorted by the **longest-lived thing it runs** (a container that stays up first, then a
service the manifest says is running, then the mesh's own process that stays up, then a bundle only):
| What a module runs | Modules |
|---|---|
| at least one container that stays up | **49** |
| no such container, but a service unit stated `running` | **19** |
| only its bundle — tools and handlers hosted by the node tools | **50** |
| only files, directories and packages | **7** |
Underneath:
| Resource | Count | Of which |
|---|---|---|
| container | **85** in 50 modules | 77 stay up, 6 run once (a step), 2 on a schedule; **71 distinct images** |
| service (an existing unit put in a state) | **31** in 21 modules | 26 stated `running`, 5 stateless (the machine's lifecycle) |
| process (the mesh's own code in a unit it writes) | **20** in 18 modules | 17 run once, 2 on a schedule, **1** stays up |
| bundle artifact | **101** in 99 modules | |
And what a check could be built from:
- **50** modules declare `listens` (an endpoint, a port, a protocol) — **48 of the 49** container
modules. A TCP or HTTP check has its target named already.
- **53** modules declare `tools`. **One** tool in the whole catalogue is a health tool by name
(`dbus_health`); **18** modules have a tool named `…_status`.
- **67** modules `require` something; the most-required provisions are `route` (36 consumers),
`x11-display` (15) and `postgres-database` (12). A database down is, today, potentially twelve
consumers failing at once.
- **No manifest declares a health check.** The container resource has no field for one — its fields are
image, environment, files, ports, volumes, arguments, names, networks, capabilities, logging, the
restart triggers and the run-once and schedule modes — and the node-engine passes the runtime no
health option. Whatever check runs is the image's own, run by the runtime by default.
## 2. What the images already ship, and what the runtime says today
Every container on every machine inspected — its state, its health, its restart count, and its
**image's** healthcheck from the image configuration:
| | |
|---|---|
| containers on the four machines | **115** (94 running) |
| of those, declared by the catalogue and running | **79 instances** of **73** of the 77 long-running containers (4 are assigned nowhere) |
| long-running catalogue containers whose **image ships a HEALTHCHECK** | **19 of 73 (26 %)** |
| …in how many modules | **7 of 45** modules with a running container (mail, the hosted database suite, a spreadsheet app, the chat client, a flow editor, the media server, the certificate authority) |
| …modules whose every container has one | **4** |
| running container modules with **no health state at all** | **38 of 45** |
| catalogue containers reporting `healthy` / `unhealthy` / `starting` | **19 / 0 / 0** |
| containers reporting `unhealthy` | 5, all exited, all on the workstation, all predating the mesh (none declared) |
**Two of the nineteen image checks were wrong under the mesh's own configuration** until a catalogue fix:
- the hosted database suite's studio — the framework binds the address the runtime puts in `HOSTNAME`, so
it answered only on its network address while its image's check asked `localhost`: *unhealthy for ever
while working* (fixed 2026-10-05);
- the flow editor — the image's check reads its settings from a path the module mounted elsewhere: *ran
fine but reported unhealthy forever* (fixed 2026-09-30).
So 2 of 19 shipped checks (≈ 10 %) gave a false *unhealthy* in the mesh's hands. Read blindly, they would
have failed two good builds at the gate.
The intervals the images chose range from **2 s to 60 s**: a database every 2 s, three web apps every
5 s, an API gateway every 10 s, the rest 30 s or 60 s. Start periods range from none to 360 s (the
antivirus). Retries from 3 to 20.
### The restart count the runtime keeps is lost
**All 94 running containers report a restart count of 0** — including the agent server that, per
[issue 268](../../04-ISSUES/268-letta-printed-its-passwords-into-its-log/00-report.md), had restarted about
a hundred times in a crash loop days before. The counter belongs to a container, and the node-engine
recreates a container whenever its declaration or a file it reads changes; the fix recreated it and the
history went with it. A restart count is only evidence if something outside the container keeps it.
### The runtime's event history is about a minute long
The runtime keeps its most recent ~250 events in memory. On the home server, a query for the last 30
minutes and for the last minute both answered ~250 events, **every one an `exec_*` event** — the image
health checks running (84 creates, 85 dies, 84 starts). A 24-hour query for container lifecycle events,
through the mesh's own `docker_events` tool, answered **zero**. The images' checks crowd every lifecycle
event out of the history within a minute. A container that died an hour ago leaves no trace there.
## 3. What the service manager says today
- **Failed system units:** 0 on three machines; 2 on the workstation, both mounts that the mesh does not
declare. One failed user unit on each of two machines, neither the mesh's.
- The mesh's own units (`mesh-ca-trust`, `mesh-filter`, the power units, the controller, the node tools,
the node-engine): all `active`, **`NRestarts` 0**.
- systemd already gives, per unit and for free: `ActiveState`/`SubState`, `is-failed`, `NRestarts`,
the time it entered its state, and — through a unit's own `ExecStartPost`/`WatchdogSec` where software
supports it — readiness. None of it is read by the mesh for a module's unit.
## 4. What the node-engine reports today
The node-engine's report (its `Report` message) carries: what applied and what failed per resource,
refusals, the declaration's digest and order, held and stray containers, reachable sockets on an adopted
machine, packet filters and the firewall found, windows (a scheduled step holding containers still), the
machine's profile, the engine's own build, outward links, rollbacks its witnesses decided, and whether it
reads `witness`. Separately, an `Alive` heartbeat with its interval.
**It carries no container state, no unit state and no health.** The controller's `node` answer for the
home server shows the consequence: its assigned modules, its strays, its filters and its capabilities —
and nothing about whether any of its 45 catalogue containers is running.
The node-engine has one judging mechanism already: the **witness** (to-be 45 §8) — a process's `witness`
field, `lease` or `ping` or `none`, judges a new build of the controller or the node tools and restores
the previous one when it is not healthy in bound. Only the two core processes use it.
A **run-once step** is already a health gate in practice: a step exiting non-zero fails the apply and
everything placed after it. **23** steps exist (6 containers, 17 processes); one of them, in the hosted
database suite, is a loop that waits up to five minutes for its analytics service's `/health` before the
services that need it start.
## 5. What the release gate judges a module by
The gate on a plan's first machine ([ADR 0236](../../02-DECISIONS/0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md);
`judgeHealth` in the controller) passes a module there when, on three judgings at least 40 s apart over at
least two minutes, within ten minutes of the send:
1. no witness on the machine put a core build back since the send;
2. the machine's last report is current and **applied** (failed or refused is broken);
3. **no condition** raised since the send names the module on that machine, or the machine as a whole
(issue 281 sorted the latter out);
4. for the controller, the node-engine and the node tools: their own health definitions (lease held and
ready; the engine's build reported; the node tools answering the bus);
5. for any other module **that declares tools**: the machine's node tools serve them.
That is the whole of it for a catalogue module. For the 49 container modules nothing in 1–5 looks at a
container: point 5 asks the node tools, which host the module's bundle, not its container. **A container
that applied and then crash-loops passes all five.** To-be 45's Phase 4 note says so: *"container state in
the node-engine's report, without which a container that crash-loops after its compose applied is seen
only through what it breaks"*.
None of the self-check's probes (D1–D13, the data and delivery probes, H-controller, H-engine, H-tools,
H-bus, DG) reads a module's container or unit state either.
## 6. The incidents, and what a declaration would have done
Every incident of the last ten days where a module applied and did not work, or looked as if it did not:
| Incident | What the gate and the self-check saw | Caught by, after | Would a declaration have caught it? |
|---|---|---|---|
| The agent server crash-looped: its database lacked the vector extension the provider never created (catalogue fix 2026-10-05) | applied; tools served (its bundle runs in the node tools, not the container); no condition | a person reading its log for another reason ([issue 268](../../04-ISSUES/268-letta-printed-its-passwords-into-its-log/00-report.md)), **about a hundred restarts**; the change readying it for the home server merged 2026-09-30, the provider's fix 2026-10-05 | **Yes, by liveness alone** — *running and not restarting* — inside the gate's ten minutes; an HTTP check on its declared `web` endpoint the same |
| A web app accepted TCP and answered no HTTP request, its database unreachable after a firewall change ([issue 145](../../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)) | *"all doing what they were told"* | a person, **after eleven hours** | **Yes, by an HTTP readiness check** within two looks (a minute at 30 s). **Not** by a TCP check — the port was open — and not by liveness |
| The identity provider's admin refused the secret the mesh minted; every consumer's client failed ([issue 179](../../04-ISSUES/179-an-adopted-identity-providers-admin-never-took-the-minted-secret/00-report.md)) | the server up and serving; its image ships no check | a person, the module having been broken since it moved to the mesh; **twice** (2026-10-01, again 2026-10-05) | **Only by a module's own check** — *the admin logs in* — which the module now runs itself and announces (ADR 0224). No container check sees it |
| The studio and the flow editor read unhealthy while working (§2) | nothing: the mesh does not read health | a person reading `docker ps` | The opposite case: **a check read without proving it would have rolled back two good builds.** A declaration must be owned by the module and proved before it is trusted |
| Two media managers refused a new recycle-bin folder; their settings step exited non-zero ([issue 279](../../04-ISSUES/279-a-folder-cannot-be-owned-by-the-account-a-module-runs-as/00-report.md)) | the apply failed → the gate failed, at once | the gate | Already caught: a step is a gate. A health declaration adds nothing here |
| The media server's event handler threw on an empty answer and was offered each event five times ([issue 276](../../04-ISSUES/276-a-handler-that-did-its-work-was-offered-it-five-times/00-report.md)) | — | the bus watchdog (S9), `max-deliveries` | **No, and it should not**: the server was healthy; handling an event is the event contract's, not health's |
| A provisioner runtime restarted several times at start until the overlay was up, then worked ([issue 058](../../04-ISSUES/058-a-provisioner-runtime-crash-loops-until-the-overlay-is-up/00-report.md)) | — | the lab | A warning: **a restart-counting check without a start period reads churn that stops as a crash loop** |
Of the seven: **two** a declaration catches that nothing catches today (the crash loop, by liveness; the
silent web app, by readiness), **one** only a module's own tool can catch (the admin), **one** shows what
an unproved check does wrong, and **three** are not health at all or are already caught.
## 7. Cost and noise
- **Reading every container's state on a machine**, health and restart count included: one `ps` of
38–49 containers took **17–21 ms**; one inspect of all of them **30–37 ms** (control node and home
server, five and three runs).
- **What the images' own checks cost today**, from the runtime's record of each check's start and end
(95 checks over 19 containers): **median 33 ms, slowest 138 ms**. On the home server they run
**77 checks a minute** — 8 containers, three of them every 2–5 s — for about **5 s of exec time a
minute**. On the control node, 11 containers at 30 s: 22 checks a minute, under 1 s.
- **The smallest machines in this mesh** have 12 cores (the control node) and 31 GB (the laptop); the busiest runs 45 catalogue containers.
One check per long-running resource at 30 s is **90 checks a minute** on the busiest machine — about
3 s of exec time a minute for in-container checks, a few hundred milliseconds for HTTP or TCP checks
the engine makes itself. The cost is not CPU; it is that every in-container check is an `exec` in the
runtime's event history, which on the home server is already one minute long.
- **Single-sample noise.** [Issue 277](../../04-ISSUES/277-one-unanswered-question-was-an-urgent-alert-nobody-could-read/00-report.md):
one DNS question unanswered on a machine starting twenty containers was raised urgent; thirty asked by
hand a moment later were all answered. Its rule — *a finding one look can be wrong about is raised on
the second look in a row* — is the runtime's own `retries` under another name; the images use 3 to 20.
The gate's own three passes 40 s apart are a third form of the same idea.
## 8. What this says
1. **Most of the catalogue has no health anywhere.** 38 of 45 running container modules, all 19
service-only modules and every bundle module have nothing that says *working* beyond *applied*.
2. **What exists is not read and not owned.** 19 image checks run, 2 were wrong in the mesh's hands,
and neither the report nor the gate nor the self-check reads any of them.
3. **The runtime's memory is too short to rely on.** Restart counts vanish with each recreate; the
event history is a minute long. Liveness must be observed and kept by the node-engine.
4. **Liveness alone would have caught the worst incident; readiness the longest; only a module's own
tool the identity provider's.** All three kinds are needed, and none is enough alone.
5. **The cost is small and the noise is known.** Tens of milliseconds a look; the two-look rule exists.
@@ -0,0 +1,141 @@
# 02 — Options
Each question of the [overview](00-overview.md), the options for it, and what the
[evidence](01-evidence.md) says about each. The recommendation is [03](03-recommendation.md).
## A. Who runs the checks
### A1 — The container runtime's own HEALTHCHECK, read by the node-engine
The image's check, or one the module sets, run by the runtime inside the container; the node-engine reads
the `health` state it keeps.
- **For:** 19 images already ship one; it runs inside the container's namespace, so a check of
`localhost` sees what the program sees; the runtime handles interval, timeout, retries and start period.
- **Against:** containers only — nothing for the 19 service-only modules, the one long-running process,
or anything a module's own tool knows. Every check is an `exec`, and on the home server the 77 a minute
already push every lifecycle event out of the runtime's history (01 §2). Two of 19 shipped checks were
wrong in the mesh's configuration; an image check the module never stated is a check nobody owns. The
runtime does nothing when a container turns unhealthy (it restarts only on exit), so the state is only
useful to whoever reads it.
### A2 — The node-engine runs every check itself
HTTP and TCP from the machine, `exec` through the runtime, a unit's state through the service manager, a
tool through the node tools.
- **For:** one runner and one reader per machine, for every hosting form; it keeps what the runtime
forgets (restarts across recreates, 01 §2); a check from outside the container tests the path a caller
takes, which issue 145 says matters; HTTP and TCP cost no `exec`.
- **Against:** the engine grows a scheduler; a check that only makes sense inside the container (a CLI,
a pid file) still needs an `exec`, which A1 does better.
### A3 — The module's own tool answers "healthy"
The bundle exposes a health tool; something calls it.
- **For:** the only kind that catches a failure of function — the identity provider's refused admin
(issue 179) — and the module knows what "working" means for it.
- **Against:** a bundle is hosted by the node tools, not by the service; a tool that answers proves the
bundle is up, not the server. One tool in the catalogue is a health tool today. And a module that judges
itself is the thing ADR 0227 rule 8 refuses for the core: *the component being replaced is never the
judge*. For readiness of function it is the only option; for liveness it must not be the only one.
### A4 — A mix, by kind (the shape the evidence points at)
The engine owns every check and every verdict. It runs HTTP, TCP and unit checks itself; it delegates an
in-container command to the runtime by setting the container's HEALTHCHECK from the declaration (so the
runtime's retries and start period do the timing) and reads the state; it asks a module's health tool
through the node tools. Liveness — running and not restarting — it observes for every long-running
resource with no declaration at all.
## B. Where the result goes
| Option | For | Against |
|---|---|---|
| **B1 — A field of the report**: per module, per long-running resource, a *state* (healthy, unhealthy, starting, unknown), since when, the failing streak and the restarts the engine counted | the report is how a machine states facts about itself; the gate already reads the last report; one place to read | a report is sent after an apply, not on a change — a container that goes bad at 03:00 waits for the next report |
| **B2 — An event on each transition** (`module.<m>.<machine>.health` changed) | immediate; the controller can keep the state | events are lost or replayed; an event alone is a sample, not a state |
| **B3 — A condition, raised by the controller** | the gate already fails a module on a new condition naming it on that machine (01 §5, point 3); the operator's conversation (ADR 0234) and the healers (ADR 0231) already act on conditions; the two-look rule and the content rule already apply | a condition is the controller's word, raised from something — it needs B1 or B2 under it |
| **B4 — A seat verb the controller calls** (`health <module>`) | always current | a pull per module per judging; a machine that is slow to answer reads as unhealthy |
B1 + B2 + B3 together is how the core already works: the engine states its state in the report and
emits the change; the controller keeps the last state per machine, raises the condition on the second
look, and clears it on the look that no longer sees it. That makes **the gate need no new rule**: an
unhealthy module raises a condition naming it, and point 3 already holds the gate on it.
## C. Liveness and readiness
- **Liveness** — *it runs*: a container running and not restarted within a window; a unit `active` and
not `failed`, `NRestarts` not climbing; a process up. Observable for every long-running resource with
no declaration. It alone catches the worst incident of the window (the crash loop). It needs a **start
period** and a **settle rule**, or it reads issue 058's churn-that-stops as a crash loop.
- **Readiness** — *it serves*: the declared check passes. Only the module can say what serving means.
- **Function** — *it does its job*: the identity provider's admin logs in. A module's own tool, and only
for what no endpoint shows.
Options: judge liveness only (cheap, no declarations, misses issue 145 and 179); judge readiness only
(misses nothing that readiness sees, but every module must declare before anything is judged); **judge
liveness everywhere at once and readiness where declared**, making the declaration required over a
migration. The last keeps the gate meaningful from the first day.
What the mesh **does** with each is a separate choice. Restarting a container that is unhealthy is what
an orchestrator's liveness probe does; the runtime here does not, and a restart hides the failure the
gate is meant to see. Options: the healers restart what stays unhealthy (ADR 0231's shape: act on what
observation raised, say whether it worked), or nothing restarts on health and the condition reaches a
person. The evidence has no case where a restart would have fixed anything — the crash loop was
restarting already.
## D. Dependency-aware health
A module requiring a provision fails when its provider fails. With 12 consumers of the database
provision and 36 of a route, a provider down would be a dozen conditions said once each, and a dozen
gates failed for something none of them did.
| Option | What it costs |
|---|---|
| D1 — Ignore it | twelve conditions for one fault; the operator learns which one matters by reading all of them; the gate blames the consumers |
| D2 — A consumer's check names the provision it exercises; when that provider is unhealthy on the record, the consumer's finding is **held under the provider's** — said as *waiting on* the provider, not raised on its own — and its gate waits rather than fails | one condition, at the provider; needs the controller to know which provider answers which consumer — it does: it composes the grants ([issue 181](../../04-ISSUES/181-an-assignment-does-not-record-which-provider-answers-it/00-report.md) is about recording it) |
| D3 — Order the judging: providers first, consumers only when their providers are healthy | simple, but a consumer broken on its own is not said while a provider is down, which is the moment it is most needed |
D2 is how issue 281 already treats a machine-level condition: what is about the machine is the
machine's, and is never pinned on the module the gate is kept on.
## E. The manifest field
Research may sketch a shape; a design doc may only name the field. Where the declaration lives:
- **E1 — On the resource** (`health` on a container, a process, a service): the check belongs to the
thing it checks; a module with three containers states three. Matches how `restart-on` and `witness`
already sit on the resource.
- **E2 — One per module** (a top-level `health`): one verdict, but a module of eleven containers (the
mail module) cannot say which one is wrong.
The sketch for E1 — a field named `health`, holding:
| Part | Meaning | Default |
|---|---|---|
| kind | `runtime` (the image's own check, adopted as is), `http` (an endpoint from `listens`, a path, the status expected), `tcp` (an endpoint from `listens`), `exec` (a command in the container), `unit` (the unit's own readiness), `tool` (one of the module's tools answering) | — |
| every | interval | 30 s; not under 10 s |
| timeout | how long one look may take | 5 s; under `every` |
| after | looks failing in a row before it is unhealthy | 3; **not under 2** (issue 277) |
| grace | after a start, how long failure does not count | 60 s |
| needs | the provision whose provider it exercises, for D2 | none |
An HTTP or TCP check names an endpoint by its `listens` name, never a port or an address, so a check
follows the machine's ports the way the endpoint does. A `runtime` kind is an explicit adoption of the
image's check, so a module that ships one *says* it does, and `module check` can prove it on a lab.
## F. Migration
- **F1 — Required at once** — every long-running resource declares, or `module check` refuses. 68
modules to touch before the next merge; nothing is judged until all are done.
- **F2 — Liveness at once, declaration required by a date** — every long-running resource is judged by
liveness from the first build; `module check` warns, then refuses after a stated date; a catalogue-wide
test counts the modules still without one, and the count only goes down.
- **F3 — Optional for ever** — modules without one are judged by liveness and tools served. The 38 of 45
container modules with nothing today would stay at liveness.
What a module without a declaration is judged by in F2 and F3: liveness of every long-running resource
plus today's five points (01 §5). A bundle-only module (50) and a files-only module (7) need no
declaration: they run nothing long-lived of their own, and the node tools serving their tools is their
liveness.
@@ -0,0 +1,120 @@
# 03 — Recommendation
From the [evidence](01-evidence.md) and the [options](02-options.md): the node-engine judges every
long-running thing a module runs — **liveness for all of them, at once, with no declaration**, and
**readiness where the module declares how** — states it in its report, and the controller raises it as a
condition on the second look, so the gate, the self-check and the operator's conversation act on it with
no new rule of their own. Every catalogue module that runs something long-lived declares its check
within a stated migration, and `module check` then requires it.
## Proposed decision text
Ready for graduation through playbook 02. The number is assigned then.
> **A module says how it is healthy, and the node-engine judges it**
>
> **Context.** The release gate (ADR 0236) judges a catalogue module on its first machine by what the
> mesh sees from outside: the declaration applied, no new condition about it or its machine, its tools
> served. None of that looks at what the module runs. Of 125 catalogue modules, 49 run a container that
> stays up and 19 a service unit; no manifest can declare a health check, the node-engine reports no
> container or unit state, and no probe reads one. 19 of 73 long-running catalogue containers have an
> image check the mesh never reads, two of which were wrong in the mesh's configuration. In ten days a
> container crash-looped about a hundred times and a web app answered nothing for eleven hours, both
> while every check passed.
>
> **Decision.**
>
> 1. **Liveness is judged for every long-running resource, with no declaration.** A container that stays
> up, a process that stays up and a service stated `running` are *alive* when running and not
> restarted more than once within the settle window after their grace period. The node-engine
> observes this itself on each tick and keeps the restarts it counted across recreates; it never
> relies on the runtime's restart count or event history. A resource held still by an open window (ADR 0189) is
> neither alive nor dead: it is said as held, and judged again when the window closes.
> 2. **A module declares how each long-running resource is ready**, in a field named `health` on that
> resource: one kind — the image's own check adopted by name, an HTTP request to a declared endpoint
> and the status expected, a TCP connect to a declared endpoint, a command in the container, the
> unit's own readiness, or one of the module's tools — with an interval (default 30 s, not under
> 10 s), a timeout under the interval, a number of failing looks in a row (default 3, **not under 2**),
> and a grace period after a start. An endpoint is named by its `listens` name, never by a port or an
> address. A check of function that no endpoint shows is a module's own tool, and only in addition to
> a check the module does not run itself.
> 3. **The node-engine runs every check and owns every verdict.** HTTP, TCP and unit checks it makes
> itself; a command in the container it hands to the runtime as that container's healthcheck and reads
> the state; a tool it asks through the node tools. Nothing else on the machine judges a module.
> 4. **The state goes in the report, its change on the bus, and a condition is the controller's.** Each
> report carries, per module and long-running resource, a state — healthy, unhealthy, starting,
> unknown — since when, the failing streak and the counted restarts; each transition is emitted as an
> event. The controller keeps the last state per machine and raises `module.<module>.<machine>`
> unhealthy as a condition when two consecutive states say so, and clears it on the first that does
> not. The release gate, unchanged, holds a module on a condition naming it; at its bound the build is
> put back.
> 5. **A provider down is said once, at the provider.** A check names the provision it exercises. While
> that provision's provider is unhealthy on the record, the consumer's finding is held under the
> provider's condition — said as waiting on it — and the consumer's gate waits rather than fails.
> What a consumer finds while its provider is healthy is its own.
> 6. **Nothing is restarted for being unhealthy.** The runtime restarts what exits, as now. A condition
> reaches a person or a healer (ADR 0231); a healer that restarts on health is its own decision.
> 7. **A declaration is proved before it is trusted.** `module check` refuses a `health` field that names
> an endpoint the module does not declare, an interval or count below the floor, or a tool the module
> does not serve. A lab bed applies every changed declaration and requires it healthy within its grace;
> an image check adopted by name is proved the same way, because two of nineteen were wrong.
> 8. **Every catalogue module that runs something long-lived declares one.** `module check` warns from
> the decision and refuses a long-running resource without `health` after the migration's date. A
> module running nothing long-lived — its bundle only, or files and packages — declares none: the node
> tools serving its tools is its liveness, as the gate judges today.
>
> **Consequences.** Liveness alone, from the first build, would have caught the crash loop inside the
> gate's ten minutes. Readiness would have caught the silent web app in a minute instead of eleven hours.
> The identity provider's refused admin needs the module's own tool, which it now has. The node-engine
> grows a small scheduler and a report field; the controller a condition kind; the gate nothing. A
> machine of 45 containers spends tens of milliseconds a look reading state, and about 3 s a minute on
> in-container commands at the default interval.
## How each rule is checked
| Rule | Checked by |
|---|---|
| 1 Liveness without declaration | node-engine unit tests over a fake runtime and service manager: a container recreated keeps its counted restarts; a restart inside grace is not counted; two restarts within the settle window after grace make it unhealthy. A lab bed that replays the crash loop (a container whose program exits at start) fails the gate within its bound |
| 2 The field and its floors | `module check` refuses each out-of-range part, with a test per refusal; a catalogue-wide test parses every `health` field |
| 3 The engine owns the verdict | a test that a declared command becomes the container's healthcheck and nothing else sets one; a test that an HTTP check dials the endpoint's current port after a port change |
| 4 Report, event, condition | controller tests: one unhealthy state raises nothing and is listed unconfirmed; two raise; a healthy state clears; the gate holds a module on that condition (an existing gate test, extended with this condition's kind). A lab replay of issue 145 — a database made unreachable — raises the consumer's condition within two looks |
| 5 Said once at the provider | a controller test with one unhealthy database provider and three consumers failing: one condition, at the provider, the consumers listed as waiting; the consumers' gates wait, not fail |
| 6 No restart on health | a node-engine test that an unhealthy container is not restarted, recreated or stopped |
| 7 Proved before trusted | the lab bed's run of every changed declaration on every catalogue merge; the replay of the studio's false *unhealthy* (a check that asks `localhost` while the program binds elsewhere) fails the bed, not a machine |
| 8 Every long-running module declares | a catalogue-wide test that counts the modules with a long-running resource and no `health`: it may only go down, and is zero by the date; after it, `module check` refuses |
## The migration
**Today:** 68 modules run something long-lived (49 with a container, 19 with a running service only);
57 run nothing long-lived and need no declaration.
1. **Build first, without declarations.** The node-engine's liveness, the report field, the events, the
controller's condition and its two-look rule, and the dependency hold. From this step every
long-running resource is judged by liveness. The catalogue-wide counter starts at 68.
2. **The seven modules whose images ship checks** adopt them by name — and, for the two that were
wrong, the fixed configuration is what the lab proves. 19 of their containers are covered at once.
3. **The other 42 container modules, and the containers without an image check in three of the seven,** declare an HTTP check on their declared endpoint where they serve
HTTP, TCP where they serve something else, a command where neither shows readiness. 48 of 49
already declare the endpoint the check needs.
4. **The 19 service-only modules** declare the unit's own readiness, or a TCP check where the unit
listens; most are machine software (a resolver, a time daemon, a session manager), where `active` and
not `failed` is already the honest answer.
5. **Modules whose function no endpoint shows** add a tool check: the identity provider (its admin logs
in), the database provider (it can create in a consumer's database), the broker. Identified as they
are met, not in advance.
6. **The date:** when the counter reaches zero, or six weeks after step 1, whichever is first. From then
`module check` refuses a long-running resource without `health`.
**What a module without a declaration is judged by, until then and for ever if it runs nothing
long-lived:** liveness of everything it runs that stays up, and the gate's five points as they stand —
applied, no witness put it back, no new condition naming it or its machine, the core's own definitions,
its tools served.
## What this does not decide
- Whether a healer restarts what stays unhealthy (rule 6 leaves it to its own record, under ADR 0231).
- Health for scheduled work — whether the last scheduled run succeeded is data the windows of ADR 0189
already carry, and belongs to that record.
- Health of what a module's events do (issue 276) — the event contract's, and the bus watchdog's.
- The core's own definitions (to-be 45 §8), which stand; a core component may later declare its own
through the same field.
@@ -0,0 +1,79 @@
---
status: graduated
initiated: 2026-10-07
became:
- 02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md
- 03-DESIGN/01-to-be/50-split-dns-on-a-machine-with-a-vpn-client.md
touches:
- 02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md
- 02-DECISIONS/0117-a-machines-uplink-is-a-seat.md
- 03-DESIGN/01-to-be/08-connectivity.md
- the node-uplink seat and its holders
- the node-engine's reconcile of a file another program rewrites
---
# 033 — Split DNS with a VPN client
## What is investigated
How a machine of the mesh resolves three kinds of name at once — the mesh's (`*.internal`), a
corporate network's reached through a VPN client, and public ones — when the VPN client writes
`/etc/resolv.conf` itself.
[ADR 0223](../../02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md) gives
every machine one resolver file listing the mesh's two resolvers and nothing else, written by the holder
of the machine's uplink ([ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)). On the
laptop, a corporate VPN client replaces that file when it connects, with its own servers and eight search
domains. The node-engine finds the file changed and writes the mesh's back on its next reconcile. From
then on, for the rest of the session, the corporate names do not resolve; in the minutes before it, the
mesh's names did not. Neither program knows the other exists.
The questions:
1. **How the VPN client sets DNS** on Linux: does it overwrite the file, use `resolvconf`, or talk to
systemd-resolved or NetworkManager; what it backs up and restores; whether it re-asserts its file.
2. **What it pushes**: how many servers, which domains, over which link, and what routes.
3. **How often the two collide** on the live mesh, and what each collision costs.
4. **The options**: per-link routing in systemd-resolved, adding the VPN's servers to the mesh's file,
forwarding the corporate domains from the mesh's resolvers, standing back while the VPN is up, and
any other — and what each means for ADR 0223 and for the machines that run no VPN.
## Why
The operator uses the VPN for work, every working day. While it is up the laptop is either cut off from
the mesh (agents saw lookups fail) or cut off from the corporate network, depending on which program
wrote the file last. A rule of the mesh that a machine's own work program silently breaks — or that
breaks the machine's owner's work — is a rule that cannot be kept as stated.
A sibling decision proposed alongside this effort, ADR 0241 (machine network health), makes the
node-engine detect and report an outside writer of the resolver file. That says the fault; this effort
asks how it stops being one.
## What it touches
ADR 0223's rule that a machine lists only the mesh's resolvers, and its rejection of a local forwarder;
ADR 0117's list of what an uplink holder declares; the networkmanager module (the uplink holder on the
laptop); the node-engine's correction of a file changed on the machine; connectivity §2.
## Where it stands
Evidence gathered read-only on the laptop and options weighed, 2026-10-07:
- [01 — The evidence](01-evidence.md): the VPN client always *moves* the resolver file aside and writes
its own (two servers, eight search domains), restores its backup on disconnect, and never re-asserts;
it does not configure systemd-resolved or NetworkManager per link on this machine. In every one of the
nine sessions since the node-engine's journal begins, the node-engine wrote the mesh's file back within
1 s to 3.5 min; once the VPN client's restore of a stale backup was itself corrected.
- [02 — Options](02-options.md): six, from systemd-resolved per-link routing to replacing the VPN
client.
- [03 — Recommendation](03-recommendation.md): on a machine that declares a VPN client, the uplink's
holder runs systemd-resolved as a local router on the machine's private address, and hands it the
VPN's servers and domains the moment the VPN client writes them; every other machine is unchanged.
A proposed decision text with how each rule is checked.
**Graduated 2026-10-07** into [ADR 0247](../../02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md)
and [to-be 50](../../03-DESIGN/01-to-be/50-split-dns-on-a-machine-with-a-vpn-client.md). The mechanism is
the recommendation's: systemd-resolved routing the VPN's domains over its link. Who runs it differs, by
the operator's decision. The resolver is a module of its own on a node seat, `node-resolver`, rather than
part of the uplink's holder, which has two forms. The VPN client's module carries the adapter, so no
setting declares a VPN client. The interim *defer* setting is not adopted. The record says why.
@@ -0,0 +1,100 @@
# 01 — The evidence
Gathered read-only on the laptop, 2026-10-07, with the VPN disconnected: the VPN client's installed
files, its own log (kept since its install, about five weeks), the strings of its binaries, the
node-engine's journal (kept for eleven days), and the machine's network state. Nothing was changed and
nothing restarted. The corporate network's domains and addresses are not reproduced here.
## The machine
- **Uplink:** NetworkManager, held by the mesh's `networkmanager` module, which tells it `dns=none` and
leaves the private network's interface (`mesh0`) unmanaged. The resolver file is the module's own,
declared from one template: the two mesh resolvers, `options timeout:1 attempts:2 edns0`.
- **systemd-resolved** is installed, as part of systemd, but disabled and not running. The name service
order is `files dns … resolve [!UNAVAIL=return]`, so with resolved stopped the resolver file is the
only DNS path.
- **The mesh's names are not in `/etc/hosts`** any more (ADR 0148 moved every name to the resolvers):
with the resolver file replaced, no mesh name resolves on the machine itself, nor in any container.
- **Five containers**: two on the node-engine network (they read the machine's file as it is, at each lookup),
three on bridges (they read it, or the runtime's embedded resolver copies its servers, when they start).
## The VPN client
**FortiClient 7.4, the vendor's own Linux client** (not openfortivpn), its scheduler running as a system
service; the tunnel is set up on demand by its `vpn` process.
**What it does at connect**, from its log, identical in all eleven sessions recorded:
1. `Inherit local DNS: No` and `DNS service resetting interval: 0` — settings pushed with the profile.
The first means the machine's own resolvers are *not* kept beside the corporate ones; the second that
it never re-asserts its file during a session.
2. It configures the tunnel device with `ip` ("fall back to using ip command") — so NetworkManager sees
the tunnel as an external device it does not manage.
3. **`Moving /etc/resolv.conf to /etc/resolv.conf.forticlient.backup`** — a rename, so the mesh's file,
inode and all, becomes the backup.
4. It writes a new `/etc/resolv.conf`: **two nameservers** (in a private RFC 1918 range, reached through
the tunnel) and **eight search domains** (the corporate network's internal and cloud zones). Its
binary carries the header it writes: `# Dynamic resolv.conf(5) file for glibc resolver(3) generated
by forticlient`, and a line saying the original is backed up and restored after the VPN disconnects.
5. **Split tunnel**: 119 routes through the tunnel — 110 host routes, mostly to a CDN's addresses, and
nine networks including the whole of `10.0.0.0/8` and `172.16.0.0/12`. The mesh's private /24 sits
inside the first, and survives only because a more specific route on `mesh0` wins.
**At disconnect:** `Moving /etc/resolv.conf.forticlient.backup to /etc/resolv.conf` — the backup taken
at connect is put back as it was then, whatever has happened to the file since. With no backup present
it logs "No DNS backup file was found. Skip."
**What it does not do on this machine.** Its binary knows systemd-resolved (if running, it *reads* the
resolvers from resolved's own file and flushes resolved's cache) and NetworkManager (it can allocate the
tunnel through `nmcli`, set a connection's DNS search domains, and drop a configuration file into
NetworkManager's `conf.d`, keeping its own backups beside the resolver file). Here it took neither path.
In no path found in the binary does it give systemd-resolved a per-link server or routing domain; it
always writes `/etc/resolv.conf` itself. Public reports agree: with systemd-resolved running, the client
replaces the stub symlink with its static file.
## The collision
The node-engine corrects a declared file that was "changed on the machine since this host last wrote it"
on its next apply or reconcile. Matching its journal to the VPN client's log:
| Session | VPN wrote its file | Node-engine wrote the mesh's back | Gap |
|---|---|---|---|
| 1 | 21:20:18 | 21:21:38 | 1 min 20 s |
| 2 | 11:51:33 | 11:54:48 | 3 min 15 s |
| 3 | 12:59:48 | 13:00:59 | 1 min 11 s |
| 4 | 12:26:30 | 12:29:55 | 3 min 25 s |
| 5 | 12:31:36 | 12:34:54 | 3 min 18 s |
| 6 | 12:42:58 | 12:44:54 | 1 min 56 s |
| 7 | 13:53:22 | 13:54:06 | 44 s |
| 8 | 14:58:37 | 15:00:35 | 1 min 58 s |
| 9 | 15:46:13 | 15:46:14 | 1 s |
- **Nine of nine sessions** since the journal begins were overwritten by the mesh, within 1 s to 3.5 min
(median about 1 min 56 s). Two earlier sessions predate the journal.
- **Each session then ran on the mesh's file** — sessions lasted from five minutes to fourteen hours —
so for all but the first minutes **no corporate name resolved**, while the tunnel and its routes stood.
The VPN client never noticed: its resetting interval is 0.
- **Sessions 4–6 were three connects within sixteen minutes**, two ended by hand after five and seven
minutes — the shape of a person reconnecting because something stopped working.
- **The VPN client's restore is itself an outside write.** On one night the uplink's holder changed (the
resolver file moved from one module's template to another's) while a session was up; when the session
ended at 02:43 the client put back its backup — the old module's file — and the node-engine corrected
it two minutes later. A restore puts back *a* mesh file, not necessarily the current one.
- **In the minutes before each correction**, the machine had only the corporate servers: no mesh name
resolved (the agents' lookups failed), and public names depended on the corporate servers. The
operator reported agents seeing `ENOTFOUND` for the AI API's public name in such a window; it is not in
any journal kept on the machine, so how often public names failed is not measured.
## What the other machines have
No VPN client runs on the anchor, the home server or the workstation. The two mesh resolvers have no
route to the corporate network's servers: they sit behind the laptop's tunnel, in ranges the laptop
routes only to itself.
## Two related faults, noted
- **One mesh resolver briefly slow under load** ([issue 277](../../04-ISSUES/277-one-unanswered-question-was-an-urgent-alert-nobody-could-read/00-report.md)):
any design that adds a hop must keep the two-resolver answer and its short timeouts.
- **The self-check asks the resolvers only from the control node** (to-be 45 §4, D2). A machine whose
own resolver file is wrong is invisible to it — the gap ADR 0241 (proposed) closes from the machine's
side.
@@ -0,0 +1,113 @@
# 02 — Options
What any answer must give, from the [evidence](01-evidence.md) and ADR 0223:
- **R1** mesh names resolve on the machine and in its containers while the VPN is up;
- **R2** the corporate domains resolve through the VPN's servers while it is up;
- **R3** public names resolve, VPN up or down;
- **R4** no listed set of servers can give two different answers to one name — musl asks every listed
server at once and takes the first reply, glibc takes the first server's "no such name" as final
([issue 262](../../04-ISSUES/262-an-alpine-container-could-not-find-a-machine-by-its-mesh-name/00-report.md),
ADR 0223);
- **R5** the VPN client keeps working unmodified — it is the employer's software, configured by the
employer's gateway; the mesh cannot change what it pushes or how it writes;
- **R6** no change on a machine that runs no VPN.
A constraint every option meets: **the VPN client always writes `/etc/resolv.conf` itself**, even with
systemd-resolved running. No option makes it stop; each decides what happens next.
## 1. systemd-resolved with per-link DNS and routing domains
resolved runs on the machine. The `mesh0` link gets the two mesh resolvers with the routing domain
`~internal` and the default route (`~.`); the tunnel link gets the VPN's two servers and its eight
domains as routing domains. resolved sends each question to the link whose domain matches longest, the
rest to the default route.
- **R1–R3 met**, by routing rather than by listing: one answer per name.
- **Containers cannot reach resolved's stub on `127.0.0.53`.** A container on the node-engine network can; one
on a bridge cannot — it reads the machine's file and gets an address that is its own loopback. resolved
can listen on further addresses (`DNSStubListenerExtra=`): on the machine's private mesh address, which
ADR 0223 already relies on being reachable from containers on a holder. The machine's file then lists
that one address. **R4 met**: one server listed.
- **Who gives the tunnel link its servers?** Not the VPN client (it never sets per-link DNS) and not
NetworkManager (the tunnel is not its device). Something must read what the VPN pushed — and the VPN
client writes exactly that, in its file, within a second of connecting. A path watch on the resolver
file, declared by the uplink's holder, can read the file's `nameserver` and `search` lines, give them to
the tunnel link with `resolvectl dns` and `resolvectl domain`, and write the mesh's file back at once —
not at the next reconcile. When the tunnel goes, resolved forgets its link's configuration by itself.
- **The VPN client's restore at disconnect** puts back the mesh's file (or an older one: the node-engine
corrects the content, as today). Nothing points at a link that is gone.
- **Cost:** a daemon on the machine (already installed; part of systemd), its configuration, the watch.
- **For ADR 0223:** it is the local forwarder 0223 rejected (its option 2), on fewer machines and with
its three objections met: not on every machine — only on one that declares a VPN client; reachable
from containers — on the private address; and it holds no copy of the mesh's names — every `.internal`
question still goes to the two resolvers, so nothing can disagree with the truth.
- **R6 met** if it is scoped to machines that declare a VPN client.
## 2. The uplink's holder adds the VPN's servers to the mesh's file
When the tunnel appears, the holder writes the mesh's two resolvers *and* the VPN's two servers, and the
VPN's search domains.
- **R4 broken, by construction.** The VPN's servers answer "no such name" for `.internal`; the mesh's
resolvers forward a corporate name to the public internet, which answers "no such name" or a public
record. glibc stops at the first listed server's answer, musl takes whichever is fastest. Whatever order
is chosen, one kind of name fails — the exact fault ADR 0223 removed the public resolver for.
- Rejected. It only works with something that routes by domain in front — which is option 1 or 5.
## 3. The mesh's resolvers forward the corporate domains while the VPN is up
- **They cannot reach the VPN's servers**: those sit behind the laptop's tunnel, routed only on the
laptop. Forwarding through the laptop would make the mesh's resolvers depend on one laptop's session.
- It would also put an employer's zones into the mesh's shared configuration, answered for every machine.
- Rejected.
## 4. Stand back: detect the VPN and defer
While the resolver file carries the VPN client's marker and its backup holds the mesh's file, the
node-engine does not correct it, and the machine's health says *deferred to a VPN client* rather than
*rewritten by another program*. On disconnect the client restores the mesh's file.
- **R2, R3 met; R1 broken for the whole session**: no mesh name resolves on the laptop, nor in its
containers. The node-engine reaches the bus by name; agents and tools on the laptop lose the mesh.
- **Cheap**: no daemon, one rule in the node-engine (and the content check on restore stays).
- Today's behaviour is the opposite trade — R1 met after a few minutes, R2 broken for the rest of the
session — and the evidence shows the operator living with it by reconnecting.
- Acceptable only as a stated, chosen degradation for a machine where the owner's work comes first;
better than the fight, worse than routing. **Useful as the fallback** of option 1 while it is not
built, if the operator prefers work names to mesh names.
## 5. A local forwarder of the mesh's own (dnsmasq) on the VPN machine
As option 1, with the catalogue's resolver program in place of resolved: `server=/<corporate domain>/…`
lines for the VPN's domains, everything else to the two mesh resolvers, listening on the private address.
- Meets R1–R4 like option 1.
- **Costs more than option 1**: a package to declare, and a configuration file to rewrite and a
process to reload on every connect and disconnect, where resolved takes per-link settings live over its
own interface and drops them with the link. dnsmasq is already the mesh's resolver module, which makes
"a second copy of the resolver on this machine" easy to misread as a third mesh resolver.
- Kept as the fallback for a machine without systemd.
## 6. Replace the VPN client
openfortivpn, through NetworkManager's plugin, hands the VPN's DNS to NetworkManager instead of writing
the file; NetworkManager with `dns=systemd-resolved` then gives resolved per-link servers and domains —
option 1 with no watch at all.
- **Not the mesh's to decide** (R5): the employer's gateway may require its own client (posture checks,
single sign-on through the vendor's flow). It would also hand the file back to NetworkManager, against
ADR 0117's `dns=none`.
- Recorded so the option is not rediscovered; not proposed.
## Summary
| | R1 mesh | R2 corporate | R3 public | R4 one answer | R6 others untouched | New on the machine |
|---|---|---|---|---|---|---|
| 1 resolved, per link | yes | yes | yes | yes | yes, if scoped | resolved, a path watch |
| 2 add the VPN's servers | some | some | yes | **no** | yes | nothing |
| 3 forward from the mesh | yes | **no** | yes | yes | **no** | nothing |
| 4 defer | **no** | yes | yes | yes | yes | one rule |
| 5 dnsmasq locally | yes | yes | yes | yes | yes, if scoped | a package, a reload per connect |
| 6 replace the client | yes | yes | yes | yes | yes | not the mesh's call |
| today (the fight) | after minutes | **no**, after minutes | mostly | yes | yes | — |
@@ -0,0 +1,89 @@
# 03 — Recommendation
From the [evidence](01-evidence.md) and the [options](02-options.md): **route by domain on the one
machine that runs a VPN client, and leave every other machine as ADR 0223 has it.** On a machine whose
operator declares a VPN client, the uplink's holder runs systemd-resolved as the machine's resolver,
listening on the machine's private address; the mesh's link carries the two mesh resolvers for the
mesh's domain and as the default route; the moment the VPN client writes its own resolver file, the
holder hands that file's servers and domains to the tunnel link and writes the mesh's file back. The
fight ends because the two programs no longer want different things: the VPN's servers are used, for the
VPN's domains only.
Why not the cheaper ones: adding the VPN's servers to the file breaks the one-answer rule that ADR 0223
exists for (option 2); the mesh's resolvers cannot reach the VPN's servers (option 3); standing back cuts
the laptop off from the mesh for whole working days (option 4). Option 4 is kept as what the machine does
*until* this is built, if the operator prefers it to today's fight — a choice for the operator, stated in
the proposed text as an interim setting, not as the decision.
What it asks of the sibling proposal, ADR 0241 (machine network health): on a machine that declares a VPN
client, the VPN client's file is not an outside writer's fault but an expected handover — it is reported
as *adopted* once the holder has taken its servers, and as a fault only if it stands longer than the
watch's bound (a few seconds), which means the watch did not fire.
## Proposed decision text
Ready for graduation through playbook 02. The number is assigned then.
> **A machine with a VPN client routes names by domain, and the VPN's file is taken, not fought**
>
> **Context.** ADR 0223 gives every machine one resolver file listing the mesh's two resolvers and
> nothing else, written by the uplink's holder (ADR 0117). A corporate VPN client on the laptop moves that
> file aside when it connects and writes its own — two servers reached through its tunnel, eight search
> domains — and puts its backup back on disconnect; it never re-asserts its file and never gives
> systemd-resolved per-link settings, even when resolved runs. The node-engine wrote the mesh's file back
> in nine of nine sessions, within 1 s to 3.5 min; from then on no corporate name resolved for the rest of
> sessions lasting up to fourteen hours, and before it no mesh name did. No resolver file can list both
> sets of servers: musl takes the first reply and glibc the first server's "no such name", so one kind of
> name would fail (issue 262).
>
> **Decision.**
>
> 1. **A machine declares that it runs a VPN client**, as a setting of its uplink's holder, naming the
> client. Nothing changes on a machine that does not.
> 2. **On such a machine the uplink's holder runs systemd-resolved** as the machine's only resolver, with
> its stub also listening on the machine's private address, so the machine's containers reach it. The
> private network's link carries the mesh's resolvers, in ADR 0223's order, as the routing domain of the
> mesh's names and as the default route. `/etc/resolv.conf` lists the machine's private address alone,
> with ADR 0223's options. resolved holds no copy of any mesh name: every mesh name is asked of the two
> resolvers, as on every other machine.
> 3. **The VPN client's file is taken, not fought.** The holder watches the resolver file; when the
> declared client writes it, the holder gives the tunnel link the file's servers and its search domains
> as routing domains, and writes the mesh's file back, within seconds — not at the next reconcile. When
> the tunnel goes, resolved drops its link's settings; the client's restore of its backup is corrected
> by content, as any change to the file is.
> 4. **The VPN's domains are never the mesh's.** They are not written to the mesh's store, its resolvers
> or any other machine; they live in resolved's state for the life of the tunnel.
> 5. **The machine says it.** The machine's network health (ADR 0241, proposed) states the VPN's file as
> *adopted*, with the tunnel link and the count of domains routed, and raises it as an outside writer
> only if it stands longer than the watch's bound or the client is not the declared one.
> 6. **Until rules 2–3 run on a machine, its operator may choose to defer**: the node-engine leaves the
> declared client's file in place while the client's backup holds the mesh's file, and the machine's
> health says *deferred to the VPN client — mesh names do not resolve* for as long as it lasts.
>
> **Consequences.** ADR 0223's rejection of a local forwarder stands for every machine but one that
> declares a VPN client; there its three objections are answered — one machine, not every machine;
> reachable from containers on the private address; no copy of the mesh's names. A machine that
> declares a VPN client and whose resolved is down has no names; its network health says so. The
> routes the VPN client adds are untouched: its routes include `10.0.0.0/8`, and the
> mesh's private network keeps working only because its own route is more specific — a fact to keep in
> mind when choosing a private range, and a check of its own.
>
> **How it is checked.**
>
> | Rule | Checked by |
> |---|---|
> | 1 Declared, nothing changes elsewhere | a controller composition test: a machine without the setting composes exactly as before; with it, the holder declares resolved and the watch |
> | 2 resolved, on the private address, mesh link and default route | a composition test reading the holder's files; live on the laptop: `resolvectl status` shows the mesh link with `~internal` and `~.`, the stub on the private address, and the machine's file lists that address alone |
> | 3 Taken within seconds | a lab drill: a container plays the VPN client (writes a file with its own header, servers and search lines, adds a tunnel link); the tunnel link carries the servers and domains and the mesh's file is back within 5 s; removing the link drops them |
> | 3 Containers | the lab drill: an Alpine container on a bridge resolves a mesh name ten times out of ten while the fake tunnel is up |
> | 4 Never the mesh's | a test that the store, the controller's renders and the other machines' files carry none of the VPN's domains after the drill |
> | 5 Said | the machine network health test: the VPN's file read as *adopted*, not raised; raised when it stands past the bound |
> | 6 Defer, while not built | a node-engine test: with the setting and the client's backup present, the file is not corrected and the health says deferred; without the backup it is corrected |
> | the routes | the machine network health's check that a mesh address still routes over the private link (ADR 0241, proposed) |
## What this does not decide
- Which VPN client the operator uses (option 6): the employer's.
- Resolution for a machine with *two* VPN clients — none exists.
- Whether the operator's corporate names should resolve inside the mesh's containers on the laptop: they
will, through the same router; restricting them is a later question if it matters.
@@ -0,0 +1,111 @@
---
status: graduated
initiated: 2026-10-07
touches:
- 00-META/glossary.md
- 00-META/checks/
- 02-DECISIONS/0006-the-substrate-and-the-control-plane.md
- 02-DECISIONS/0008-a-context-owns-its-store.md
- 03-DESIGN/01-to-be/06-the-controller.md
- the descriptions of every module's tools and every seat's verbs in the catalogue
became:
- 02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md
- 03-DESIGN/01-to-be/49-the-mesh-in-domains.md
---
# 034 — The mesh in domains
## What is investigated
The operator, 2026-10-07: *"I wanted to develop our nox-mesh domain driven. Meaning every concept
should fit into some domain and we try to come up with a common knowledge base/glossary/jargon for our
application. We kind-of do this already I think, yet sometimes, you return different words for existing
concepts."*
Two terms from domain-driven design are used throughout this effort, so they are explained once here.
A **domain** (in the literature, a *bounded context*) is an area of the system inside which every word
has exactly one meaning, and which owns the concepts that meaning describes. A **ubiquitous language** is
the set of words a domain uses the same way in conversation, in documents and in code. The glossary
([`00-META/glossary.md`](../../00-META/glossary.md)) is this repository's attempt at a ubiquitous
language for the whole mesh, without domains.
The effort asks five questions:
1. **Which concepts are in use**, in the glossary, the decision records, the designs and the issue
reports, and in the words the running mesh's tools use to describe themselves?
2. **Into which domains do they fall?** Each domain gets a purpose, the concepts it owns, the one word
for each, and its relation to the others. The operator's starting guess — building and delivering
changes; running modules on machines; health and alerts; data and backups; people, conversation and
approvals; the network — is tested, not assumed.
3. **Where do the words clash?** Two or more words for one concept (a *synonym*), or one word for two
concepts (a *homonym*), each with evidence and a proposed single word.
4. **How is the glossary rule checked?** [`AGENTS.md`](../../AGENTS.md) says *a rule states how it is
checked*, and "one name per thing" is checked by nothing today.
5. **How should the glossary be organised by domain?** Described only; the glossary itself changes when
this effort graduates, not before.
## Why
Because the drift is measurable, and it costs. The glossary retired *control plane* on 2026-09-16; three
weeks later 29 occurrences stand in 12 to-be designs — the documents that tell somebody what to do — and
73 files created after the retirement use it. It retired *host agent*, *the host* and `mesh-host` for the
**node-engine** on 2026-10-05; the descriptions of four tools the running mesh serves still say *"the host
will restore"*, and the glossary's own entry for node tools says *"a host-side process the host
supervises"*. The glossary defines **node**, while the decisions and designs of the last week use
*machine* twice as often as *node*, and the console's own tools are called `mesh_machine` and list
"machines". A reader — person or agent — who meets two words assumes two things, and an agent answering
from these documents repeats whichever word it read last. That is the operator's complaint, observed.
## What it touches
The glossary and how it is kept; the checks in [`00-META/checks/`](../../00-META/checks/); the seven
contexts of the controller that [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md)
named and [ADR 0008](../../02-DECISIONS/0008-a-context-owns-its-store.md) gave each its own store, which
are the nearest thing the mesh already has to domains; and the descriptions of the tools and seat verbs
that the modules in the catalogue serve, which are where an agent meets the mesh's words most often.
## The documents
| | |
|---|---|
| [01 — The concepts in use](01-the-concepts-in-use.md) | the inventory: what was read, how it was counted, what the glossary holds and what it lacks, and the words the running tools use |
| [02 — The domains](02-the-domains.md) | the proposed split into ten domains plus the repository's own, each with purpose, concepts, words and relations; the operator's guess tested |
| [03 — The clashes](03-the-clashes.md) | twenty clashes with evidence and a proposed word for each, the five most costly first |
| [04 — How the glossary is checked](04-how-the-glossary-is-checked.md) | a proposed check over hq documents and over the catalogue's tool descriptions; described, not built |
| [05 — A glossary by domain](05-a-glossary-by-domain.md) | how the glossary would be organised; described, not written |
## Findings so far
- **The mesh already has domains under another name.** ADR 0006 named seven *contexts* of the controller —
inventory, config, connectivity, provisioning, delivery, observability, identity. Only three of them have
a store today (`inventory`, `identity`, and `licences`, which is not one of the seven), and the words the
mesh has grown since (seat, delivery, condition, ask, data class) are not organised by them. The
proposed split in [02](02-the-domains.md) keeps five of the seven, renames one, and adds four.
- **The operator's guess survives mostly intact.** Five of its six domains hold up. It lacks a domain for
*identity and access* (credentials, grants, licences), one for *provisioning* (provider and consumer),
the *core* the rest runs on (controller, bus, store, lease, genesis), and the *module* itself, whose
manifest is the language every other domain reads. *Health and alerts* is better called *health and
repair*: the mesh says *condition*, not *alert*.
- **The glossary is short of the vocabulary in use, and contradicts itself twice.** It has 33 entries. At
least 40 words used in more than ten decision records each — *module*, *manifest*, *assignment*,
*machine*, *provider*, *condition*, *gate*, *tier*, *operator* among them — have no entry. It defines the
console as a module and, further down, says node tools replaced that name; it says the deprecated broker
holds no seat and, in the entry for *claim*, that it claims `mesh-broker`.
- **Most drift is in two places**: governing designs written before a word was retired and never
revisited, and tool descriptions, which nothing compares with the glossary at all.
- **Homonyms cost more than synonyms**, and a word list cannot catch them. *Plan*, *push*, *release*,
*gate*, *tier*, *ask*, *store* and *record* each mean two to four things today, several of them in the
verbs of the same seat.
## Questions for graduation
- **Machine or node.** One concept, two words, both heavily used; [03](03-the-clashes.md) recommends
*machine* in prose and keeps `node` only as an identifier (a scope's value and the `node-` prefix of seat
names). This is the largest rename the effort proposes, and the operator's call.
- **Domain or context.** The repository already says *context* (ADR 0006, ADR 0008) for an owner of
records with its own store. Using *domain* for the vocabulary partition beside it would be the effort's
own synonym. [02](02-the-domains.md) proposes that a domain is the vocabulary and ownership boundary and
a context is one store inside it, and asks for that to be decided rather than left to usage.
- **Which documents the check holds to the list**, and from which date ([04](04-how-the-glossary-is-checked.md)).
- **Where the tool-description check runs**: in the catalogue repository's merge check, or as a probe of the
running mesh, or both.
@@ -0,0 +1,148 @@
# 01 — The concepts in use
What was read, how words were counted, what the glossary holds, what it lacks, and which words the
running mesh's tools use. The domains in [02](02-the-domains.md) and the clashes in
[03](03-the-clashes.md) are drawn from this.
## What was read
| Source | Size on 2026-10-07 | How |
|---|---|---|
| the glossary | 33 entries | read whole |
| decision records (`02-DECISIONS/`) | 225 records | counted; the seven-contexts record (0006), its store rule (0008) and the records from 0220 to 0240 read for their words |
| to-be designs (`03-DESIGN/01-to-be/`) | 49 documents | counted; the controller (06), connectivity (08), and 45 to 48 read for their words |
| as-is designs (`03-DESIGN/00-as-is/`) | 17 documents | counted |
| issue reports (`04-ISSUES/`) | 291 issues | counted |
| research (`01-RESEARCH/`) | 33 efforts | counted; research 005 (*which domains the catalogue groups into*) read whole, as prior art |
| the running mesh's tools | 88 modules serving 535 tools, plus the verbs of 15 seats every machine holds and 5 seats held once for the mesh | listed through the console's read tools (overview, machine, search, describe); nothing was called that changes anything |
**How words were counted.** A case-insensitive, whole-word match per file; the number given is the number
of *files* containing the word, not of occurrences, unless a statement says otherwise. Where a file's age
matters, its age is the date git first records it. The tool descriptions are the first line each tool
gives of itself in the console's listing, which is cut at about 160 characters, so a word that occurs only
late in a long description is undercounted there.
## What the glossary holds
Thirty-three entries, in seven sections: the mesh and its machines; what runs the mesh; what the mesh
stores and serves; how modules relate to the mesh; the surfaces; how a change reaches the machines; the
operator's machine. Retired words are marked three ways — struck through (`~~flavor~~`), *"Replaces
**x**"*, and *"Not x"* — so no program can read the list of retired words from it reliably today.
**It contradicts itself in two places.**
- **The console.** The surfaces section defines the console as *"the module (`mesh-console`) that puts the
mesh's tools in front of whoever is on a machine"*. The operator's machine section, further down, defines
node tools as the runtime whose *"serving mode on loopback is what was called **the console**"*, and
says node tools *"replaces 'console' as the module's name; console remains the word for the person's end
of it."* Both entries are current.
- **The deprecated broker.** Its entry says it has *"no seat, not foundation"*. The entry for *claim*
says *"the `mesh-controller`, `postgres` and `lavinmq` modules claim the `mesh-controller`, `mesh-store`
and `mesh-broker` seats"*, and the entry for *bus* says the bus is held by the `mesh-broker` seat.
**It uses a word it retired.** The node tools entry says *"a host-side process the host supervises"*,
eight lines after the node-engine entry retired *the host*.
## What the glossary lacks
Words used in more than ten decision records each, with no entry of their own (files containing each, of
225 records):
| Word | Records | Word | Records | Word | Records |
|---|---|---|---|---|---|
| machine | 177 | operator | 125 | catalogue | 120 |
| declaration | 106 | manifest | 102 | reach | 101 |
| provider | 95 | person | 91 | adopted | 84 |
| tool | 76 | holder | 75 | grant | 65 |
| assignment | 60 | secret | 56 | scope | 55 |
| apply | 54 | resolver | 51 | genesis | 46 |
| verb | 45 | firewall | 43 | route | 42 |
| converged | 40 | private network | 37 | connectivity | 34 |
| the lab | 33 | proxy | 31 | overlay | 30 |
| upgrade | 27 | gate | 25 | condition | 23 |
| endpoint | 23 | reconcile | 22 | vault | 20 |
| hub | 19 | finding | 19 | health | 19 |
| licence | 16 | probe | 15 | tier | 14 |
| membership | 14 | anchor | 14 | backup | 11 |
*Module* itself — the word the whole mesh is built on — has no entry. Nor do the words of the four
designs written in the last week: *condition*, *healer*, *self-check*, *watchdog*, *hand-act*, *drill*,
*lease*, *epoch* (to-be 45, *a core that cannot fail silently*); *data class*, *restore point* (to-be 43
and ADR 0233); *converged* and *adopted* as the two states a machine is in (the controller's `nodes` verb
answers with them).
## The seven contexts, as they stand
ADR 0006 split the controller into seven **contexts** — areas of record, each owning its own store
(ADR 0008) — and to-be 06 tabulates them:
| Context | Owns, in ADR 0006's words | A store of its own today? |
|---|---|---|
| inventory | nodes, modules, assignments, versions | yes |
| config | settings, secrets, and deriving them onto nodes | no |
| connectivity | overlay, resolution, exposure, filtering, certificates | no |
| provisioning | resource grants between modules | no |
| delivery | source to artifact to node | no — deliveries are kept by the `mesh-delivery` module since ADR 0239 |
| observability | health, logs, metrics, alerts | no |
| identity | agents, humans, services, authorisation | yes |
The store has a third database the seven do not name, `licences` (ADR 0183). The seven are the nearest
thing the mesh has to domains, and they were drawn from one question — *does answering this need more than
one node?* — which is a test for what the controller owns, not for where a word belongs. Seat, claim,
bench, delivery group, condition, ask, data class and every word of the operator's conversation arrived
after them, and none of those records says which context its words belong to.
Research 005 asked a neighbouring question in August — which *modules* group into domains — measured which
modules change together, and found that only the reachability modules do. Its conclusion, recorded in ADR
0009, was that modules are not grouped into packages; it did not touch the vocabulary. This effort groups
*concepts and words*, not modules: a module may serve several domains, and the domains are not folders in
the catalogue.
## The words the running tools use
The console's listing gives each tool's first line. Over the 535 module tools (the seat verbs are counted
separately below):
| Word | Tools | Modules | Note |
|---|---|---|---|
| machine | 39 | 25 | the word most modules use for where they run |
| node | 27 | 5 | 21 of the 27 in the agent's configuration module; the others the vault, the bus server, the packet filter and a flow editor whose own objects are called nodes |
| the host / host's | 6 | 2 | the container module's `start` and `stop` tools, meaning the node-engine; the remaining uses are a network's hosts |
| operator | 28 | 23 | the person; consistent with the glossary |
| user | 30 | 12 | almost all a wrapped program's own accounts (an identity provider's users, the forge's users, a database's users) |
| install | 43 | 16 | a package installed on a machine, never the mesh's assignment |
| condition | 0 | 0 | — while the controller's verb is `conditions` |
| seat | 0 | 0 | the module tools never name a seat; only the console and the controller do |
| deploy, rollout, pipeline, control plane | 1, 0, 0, 0 | — | the one *deploy* is a wrapped program's own (a flow editor's) |
The seat verbs say:
- `<machine>/node-service-manager.start` and `.stop`: *"the answer says **the host** will restore what its
declaration says at its next apply"*.
- the bus server module's `connections` tool: *"a machine's **host** `node.<machine>`, a machine's runtime
`<machine>.node-tools`"* — three words (host, node, runtime) for two programs, in one line.
- `mesh-controller.plan`: *"What one machine would run, and why: **the declaration** the mesh would send
it"*; `mesh-controller.plans`: *"What the last merges produced … the tiers"*;
`mesh-controller.delivery-plan`: *"The delivery plan of a diffset"*. Three meanings of *plan* on one
seat.
- `mesh-controller.queue`, `.cancel`, `.clear`: *"Every **ask** in the build queue"* — while the glossary's
*ask* is a request for the operator's input.
- `mesh-delivery.release`: *"A person's word that a held delivery goes on"*; and
`anthropic-licence-manager.release`: *"Unbind a consumer"*.
- `mesh-controller.doctor`: *"The **self-check** …"* — the verb and the concept named differently.
- the nftables module's tool is called `firewall_rules`, and the seat it holds on every machine is
`node-packet-filter`, whose verb is `rules`.
- `mesh-controller.nodes`: *"Every **machine** the mesh knows"*; the verb is named for nodes and answers
in machines.
- `node-build-agent` is the name of a seat every machine holds, while the glossary avoids *agent*
*"because the word already means two other things here, the build agent and the operator's coding
agent"*, and ADR 0237 calls the same role *the build seat*.
- `operator-channel` is a seat of the mesh, held by the conversation's router (ADR 0234); the glossary
names the router and the `channel` bench but not the seat.
**Vendor words are not drift.** A module that wraps a program speaks that program's language where it
describes the program's own objects: an identity provider has *users* and *realms*, a media manager has
*releases*, a flow editor *deploys*. That is correct and must stay so; a check that flagged it would be
wrong ([04](04-how-the-glossary-is-checked.md) scopes the list to the mesh's own words for that reason).
The drift is where a tool describes **the mesh's** objects — its machines, its engine, its seats — in a
word the glossary retired or never had.
@@ -0,0 +1,292 @@
# 02 — The domains
A proposed split of the mesh's concepts into domains, tested against the operator's starting guess and
against the seven contexts of ADR 0006.
## What a domain is here, and how one was drawn
A **domain** is an area of the mesh that **owns** a set of concepts: inside it each concept has one
word, and the domain decides what that word means. Another domain may use the word — that is what makes
it a shared language — but may not change its meaning. Two relations between domains are named:
- **upstream / downstream.** Domain A is *upstream* of B when B depends on A's concepts and A does not
depend on B's. A change of meaning upstream ripples down; never the other way.
- **shared word.** A word two domains both use. It is safe when both mean the same thing and one of
them owns it; it is a clash when they mean different things ([03](03-the-clashes.md)).
Three tests decided where a concept belongs, in this order:
1. **Who records it.** The context, module or seat that writes the record of it owns it (ADR 0008: *a
context owns its store*). A condition is the controller's record; a delivery is `mesh-delivery`'s.
2. **Whose decision records define it.** The records that introduced the word, and the to-be design that
names those records in its frontmatter.
3. **Which other words it is never used without.** *Walk* never appears without *delivery*, *tier* and
*gate*; *healer* never without *condition* and *budget*.
**Domain and context.** ADR 0006's *context* is narrower than a domain: it is one owner of records, with
one store, inside the controller. This effort proposes that a domain may hold zero, one or several
contexts, and that the two words are kept apart: **domain** for the area of vocabulary and ownership,
**context** for a store-owning part of the controller. Whether to adopt *domain* at all, or to widen
*context* to mean it, is a question for graduation ([00](00-overview.md)); this document uses *domain*.
## The split
Ten domains of the mesh, and one of this repository. They are ordered from the most upstream to the most
downstream; each lists its purpose, the concepts it owns with the proposed word for each, and its
relations.
### 1. Module — what a module is and declares
**Purpose.** To say what one module is, in the one document every other domain reads: its manifest.
**Owns.** *module*; *manifest* (the file a module is defined by — not *module definition*, not
*module.yml* in prose); *resource* (what a manifest declares a machine must have: a file, a service, a
container, a package); *declares* (the verb for what a manifest states); *provides* / *requires* (as
written in a manifest; their meaning is Provisioning's); *claim* (a manifest's request to hold a seat);
*setting* (a value a manifest declares and an assignment gives); *kept region*; *bundle* and *tools
bundle*; *tool* (what a module serves) and *verb* (what a seat carries); *invokes*; *upgrade policy*
(`roll`, `together`, `record`); the declarations of *data* and *health* (their meaning is Data's and
Health's).
**Relations.** The most upstream domain. Every other domain reads the manifest, which makes its words a
**published language** — a vocabulary fixed in a schema that others conform to. It depends on nothing
but the Core's notion of a scope.
**Against the seven contexts.** None; the seven describe the controller, and the manifest is not the
controller's.
### 2. Core — the mesh's own machinery
**Purpose.** To keep the controller, the bus, the store and every machine's engine running and agreed,
so that every other domain has something to run on.
**Owns.** *controller*; *control-node*; *node-engine*; *node tools* (the tool runtime on every machine);
*foundation*; *store* (the one database server); *bus*; *broker seat*; *genesis* (raising the foundation
on an empty mesh); *lease* and *epoch* (ADR 0229: which controller may send, and the order of what it
sent); *context* (a part of the controller that owns a store).
**Relations.** Upstream of every domain but Module. Every other domain is a **conformist** to it — it
uses the bus and the store as they are and has no say in their shape.
**Against the seven contexts.** Not one of them: ADR 0006 described what the controller owns, and the
Core is the controller itself.
### 3. Placement — what runs on which machine
**Purpose.** To decide, send and apply what each machine runs, and to say why.
**Owns.** *machine* (or *node* — see clash 1); *operator account*; *assignment* (a module put on a
machine); *seat*, *scope* (machine, site, mesh), *capacity*, *bench*, *kind*, *holder*, *holding*;
*depends on a seat*; *declaration* (what the controller sends one machine — see clash 9); *send* (the
controller giving a machine its declaration; `push` is the verb that asks for it — clash 4); *apply* (the
node-engine making a machine match its declaration); *reconcile*; *converged* and *adopted* (a machine's
two states, ADR 0100); *pin* is Provisioning's, not this domain's.
**Relations.** Downstream of Module and Core. Upstream of Provisioning, Connectivity, Data and Health,
which all need to know what runs where. Shares *setting* with Module (a manifest declares it, an
assignment gives it its value) and *declaration* with Module (clash 9).
**Against the seven contexts.** ADR 0006's *inventory* (nodes, modules, assignments, versions) and the
derivation half of *config*.
The operator's guess called this *running modules on machines (seats, benches, claims)*, and it holds.
*Claim* belongs to Module (a manifest claims) and *holding* to Placement (an assignment holds), which is
the distinction the glossary already draws in *installed / holding*.
### 4. Provisioning — one module serving another
**Purpose.** To resolve which module serves a provision for which consumer, and to wire the two.
**Owns.** *provision*; *provider*; *consumer* (the module that requires); *pin* (a person's choice of
provider); *endpoint* (where a consumer reaches its provider); *pair credential* (the two ends of one
credential; the credential itself is Identity's); *retire* (a provider stops serving a consumer the mesh
no longer asks for, ADR 0230).
**Relations.** Downstream of Module (requires/provides), Placement (what runs where) and Identity (the
credential). Upstream of Data, which watches what a provider keeps for a consumer. Shares *consumer* with
Identity's licences (a licence is bound to a consumer) — the same idea, safely shared.
**Against the seven contexts.** ADR 0006's *provisioning*, unchanged.
The operator's guess has no such domain; its words are spread across *running modules* and *data*. They
are a language of their own: 95 decision records say *provider*, and four seat verbs (`pin`, `unpin`,
`retire`, `cleanup`) are about nothing else.
### 5. Identity and access — who may do what
**Purpose.** To issue, hold, rotate and check the credentials and grants by which modules, machines,
agents and the operator act.
**Owns.** *credential* (anything that proves an identity); *secret* (a credential or other value the vault
keeps sealed); *vault*; *grant* (what an identity may call or reach); *bus account* and *membership*; *rotate*;
*licence*, *binding* (a consumer bound to a licence), *adopt* (taking in a licence that existed before);
*proof* (what an authorising answer carries: a verified sender, a one-time code, a key's touch);
*operator identity* (the list the router checks a sender against).
**Relations.** Downstream of Core (the bus's accounts) and Placement (what is assigned where). Upstream of
Provisioning, Operator and conversation, and Change and delivery (whose builds are signed and whose
merges are approved).
**Against the seven contexts.** ADR 0006's *identity*, plus the secrets half of *config*, plus the
`licences` store, which is a context the seven did not name.
The operator's guess has no such domain. It is not small: *credential* is in 78 decision records, *grant*
in 65, *secret* in 56, and two of the three context stores that exist today (`identity`, `licences`) are
its.
### 6. Change and delivery — a commit on its way to the machines
**Purpose.** To take one commit from its pull request to every machine that should run it, and to put it
back when it fails.
**Owns.** *pull request*, *commit*, *trunk*; *merge check* (the two statuses a pull request carries, the
*merge gate* and the *repository check*, ADR 0238); *build*, *build seat*, *build queue* and the entry in
it (clash 7); *package* and *package-registry*; *artifact* and *artifact store*; *catalogue* (what the mesh
holds of every module, at which commit); *delivery*, *delivery group*, *delivery plan* (its *build plan*
and *deploy plan*); *walk*; *tier* in the sense of a step of a walk (clash 8); *first machine* and its
*gate* (clash 6); *rollback*; *the lab*, *scenario*, *replay*.
**Relations.** Downstream of Module, Core, Health (the gate reads health) and Data (a module holding
irreplaceable data is recorded, not rolled out, ADR 0236). Upstream of Placement in time — a walk asks
the controller to send — and of Operator and conversation, which carries its held deliveries to a person.
**Against the seven contexts.** ADR 0006's *delivery*, now owned by the `mesh-delivery` module rather than
by a context of the controller (ADR 0239).
The operator's guess called this *building and delivering changes*; it holds, with *change* kept for a
diff (the glossary already says so) and *delivery* for the thing that travels.
### 7. Health and repair — what is wrong, and putting it right
**Purpose.** To notice what is wrong with the mesh, say it once, repair what may be repaired unattended,
and record what was done by hand.
**Owns.** *health* (a module's or core component's statement of being well, ADR 0240); *probe*;
*signal* and *watchdog*; *self-check* (clash 13); *condition* (one open fact about something wrong, with a
key and a severity); *healer*, its *repair* and *budget*; *drill*; *hand-act*; *observation* (ADR 0231:
only observation raises or clears a condition).
**Relations.** Downstream of everything it watches. Upstream of Change and delivery (the gate) and of
Operator and conversation (a condition becomes a message).
**Against the seven contexts.** ADR 0006's *observability* — whose row says *"health, logs, metrics,
alerts"*. The mesh never built *alerts*; it built conditions, and the word *alert* survives only in a
wrapped dashboard program's tool. The domain is renamed accordingly: *observability* names the watching,
and half of what this domain owns is repair.
The operator's guess called this *health and alerts*. It holds as a domain, under the word the mesh
uses: *condition*, not *alert* (clash 14).
### 8. Data — what the mesh keeps, and getting it back
**Purpose.** To know every item of data a module holds, how precious it is, and to keep it recoverable.
**Owns.** *data* as a module declares it (ADR 0233); *data class* — *irreplaceable*, *rebuildable*,
*cache*; *backup*; *restore point*; *restore* (beside the live data, never over it); the bus's *stream
snapshot* (ADR 0235); *binding* of a consumer to its data moving only by a person (ADR 0232).
**Relations.** Downstream of Module (the declaration), Placement (where the data is) and Provisioning
(data a provider keeps for a consumer). Upstream of Change and delivery (irreplaceable data holds a
module back from rolling out) and of Health (data is watched).
**Against the seven contexts.** None. Data was not foreseen in ADR 0006; the self-check measures it today
(`mesh-controller.data`), and every machine's `node-backup` seat keeps it.
The operator's guess called this *data and backups*, and it holds.
### 9. Connectivity — how machines and modules reach one another
**Purpose.** To make every machine and module reachable by name where it should be, and unreachable
where it should not.
**Owns.** *private network* (clash 15); *resolver*; *uplink*; *hostname*; *zone*; *proxy* and *public
name*; *packet filter* (clash 16); *intrusion prevention* and *ban*; *reach* (how far an
assignment's endpoint may be reached from, ADR 0138); the roles a machine plays for the network (the anchor, a hub).
**Relations.** Downstream of Placement (what runs where) and Identity (who may reach). Upstream of
Provisioning (an endpoint must be reachable) and of Health (an unreachable machine is a condition).
**Against the seven contexts.** ADR 0006's *connectivity*, unchanged — and the one domain research 005
found real in the catalogue's history, where its modules are the only ones that change together.
The operator's guess called this *the network*, and it holds.
### 10. Operator and conversation — the person the mesh works for
**Purpose.** To let the mesh and its operator talk: tell, ask, answer, and act only on an answer it can
trust.
**Owns.** *operator* (the person who runs the mesh; clash 17); *person* (any human, as against the mesh
acting unattended); *console* (the person's or agent's end of node tools, clash 5); *channel* and *intake*
benches; *router* and the `operator-channel` seat it holds; *responder*; *ask*, *authorising ask*,
*answer*; *operator message* and *untrusted input*; *reference*; *notification* on a desktop.
**Relations.** Downstream of Health (conditions become messages), Change and delivery (a held delivery
waits for a person's word) and Identity (proofs, the operator's identities). An authorising answer acts in
whichever domain asked, through that domain's own verb; the conversation owns the asking, never the act.
**Against the seven contexts.** None. ADR 0006 named `api`, *"the one interface every surface speaks
to"*, as an interface rather than a context; the console and the conversation are what it became.
The operator's guess called this *people, conversation and approvals*. It holds, with one adjustment:
an **approval** is not this domain's concept. The approval of a merge is Change and delivery's (a human
approves the pull request); the release of a held delivery is `mesh-delivery`'s verb; the conversation
carries both as asks. *Approval* is in five decision records; *ask* in 58.
### 11. The record — how this repository works
Not a domain of the mesh but of the company's way of building it, and listed because its words collide
with the mesh's. **Owns** *research effort*, *decision record* (and *supersede*, *progressive insight*),
*design* (*as-is*, *to-be*), *issue*, *playbook*, *check* (one of `00-META/checks/`), *graduation*,
*hand-off*. Its *record* and *check* collide with the mesh's (clashes 11 and 12).
## The operator's guess, scored
| Guess | Verdict |
|---|---|
| building and delivering changes | **holds** as *Change and delivery* |
| running modules on machines (seats, benches, claims) | **holds** as *Placement*, with *claim* moved to Module |
| health and alerts | **holds, renamed** *Health and repair*: the mesh's word is condition, and half the domain is repair |
| data and backups | **holds** as *Data* |
| people, conversation and approvals | **holds** as *Operator and conversation*, with *approval* owned by the domain whose act is approved |
| the network | **holds** as *Connectivity* (ADR 0006's word) |
| — | **added**: *Module* (the published language), *Core* (what the rest runs on), *Provisioning* (ADR 0006 had it), *Identity and access* (ADR 0006 had it) |
## A map
```
Module (published language)
|
Core
|
+--------------------------+---------------------------+
| | |
Identity -------------> Placement |
| / | \ |
| / | \ |
v v v v |
Provisioning <------- Connectivity Data |
| | |
+-------------> Health and repair <------------------+
| |
v v
Change and delivery Operator and conversation
| ^
+-------------------+
(a held delivery waits for a person's word)
```
Arrows point downstream. Change and delivery appears low because it reads health and data; in time it
runs first, and asks Placement to send.
## What this split does not decide
- **Module ownership.** A module may serve several domains (the controller serves five). The domains
are a partition of words and records, not of the catalogue — research 005 already found that grouping
modules into folders records nothing.
- **Whether every domain gets a context with its own store.** ADR 0008's rule applies where a domain's
records live in the controller; *Change and delivery* has moved its records to a module instead, and
that is equally sound.
- **The operator's machine.** The desktop seats (display session, launcher, clipboard, notifier) are
Placement's seats like any other, and their words are each program's own. Research 018 and 026 did not
introduce a vocabulary that needs a domain.
@@ -0,0 +1,306 @@
# 03 — The clashes
Twenty places where the mesh's words disagree with each other. A **synonym clash** is two or more words
for one concept; a **homonym clash** is one word for two or more concepts. Each entry gives the evidence
— which documents and which tool descriptions say what — and a proposed single word, with the domain
([02](02-the-domains.md)) that would own it. Counts are files containing the word, as in
[01](01-the-concepts-in-use.md), unless they say *occurrences*.
The first five are ordered by cost: how often the clash appears where somebody is told what to do (a
to-be design, `00-META`, a tool description), and how likely it is to make a reader act on the wrong
thing. The other fifteen follow by domain.
A decision record keeps the words it was written with (its immutability rule), so a retired word found in
a decision record is not a fault and is not counted as drift below unless the record was written after
the word was retired.
## The five that cost most
### 1. *machine* and *node* — one concept, two words (synonym)
**Evidence.**
- The glossary defines **node**: *"a machine in the mesh"*, and has no entry for *machine*.
- In the decisions and designs of the last week (ADRs 0220 to 0240, to-be 45 to 48), *machine* occurs 706
times and *node* 349 times. Before ADR 0150, 6 record titles say *machine* and 9 say *node*; from ADR 0150
on, 17 and 17.
- In the tool descriptions, *machine* is in 39 tools of 25 modules; *node* in 27 tools of 5 modules, 21 of
them one module's (the agent's configuration, where *node* is also a scope's value).
- The console's own tools are `mesh_machine` and an overview that lists *"machines"*. The controller's verb
is `nodes` and answers *"Every **machine** the mesh knows"*; `node` answers *"What one machine
reported"*. Every seat a machine holds is named `node-…`.
- The glossary itself switches inside one entry: *control-node* is *"the one node that also holds…"*, and
the next sentence says it *"is not a separate kind of machine"*.
**Proposed word: _machine_** (Placement). It is what the operator, the tools and the newest records already
say, and it needs no explaining. *Node* survives only as an **identifier**: the value of a scope
(`scope: node`), the `node-` prefix of seat names, and *control-node* until it is renamed. The glossary
would map the identifier to the word, as it does for `mesh-host` today. The alternative — *node* everywhere —
is defensible and cheaper in code, and is what the glossary says; it is listed in
[00](00-overview.md) as the operator's call because either choice renames something heavily used.
### 2. *node-engine*, *mesh-host*, *the host*, *node host*, *host agent* — one program (synonym)
**Evidence.**
- The glossary retired *host agent*, *the host* and `mesh-host` for **node-engine** on 2026-10-05, keeping
`mesh-host` as the name of the repository, binary and service unit until they are renamed.
- Its own node tools entry still says *"a host-side process **the host** supervises"*.
- To-be 05 is titled *the node host*; *the host* is in 34 to-be designs and *node-engine* in 7.
- Of the decision records written since the retirement, ADR 0222 says *the host* six times, ADR 0236 six
times beside *node-engine* nine times, and ADR 0223 three of each.
- Tool descriptions: `<machine>/node-service-manager.start` and `.stop`, and the container module's
`start` and `stop`, say *"the answer says **the host** will restore …"*. The bus server's `connections`
tool says *"a machine's **host** `node.<machine>`"* — naming the program *host* and its bus account
*node*.
- *Host* also keeps its ordinary meanings: a network host (the ssh client module's tools), *hosting* a
service, and the *host's* network in a container runtime.
**Proposed word: _node-engine_** (Core), as the glossary already says; *the host* and *host agent* retired
for the program. Because *host* also has ordinary networking meanings, the check in
[04](04-how-the-glossary-is-checked.md) can retire only the phrases *the host* and *host agent* and the
identifier `mesh-host` outside code spans, not the bare word. If clash 1 is decided for *machine*, the
program's name reads more naturally as **machine engine**; this effort does not propose renaming it again.
### 3. *plan* — four things (homonym), and *change plan* / *release plan* / *delivery plan* (synonyms)
**Evidence.** On one seat, the controller's:
- `mesh-controller.plan` — *"What one machine would run, and why: the declaration the mesh would send it"*.
A **machine's declaration**, previewed.
- `mesh-controller.plans` — *"What the last merges produced and where each stands (ADR 0162): the
tiers"*. ADR 0162 calls it *a tiered plan*; ADR 0218 and ADR 0236 call it the **release plan** (seven
times in ADR 0236: *"Every release plan's first machine passes a gate"*).
- `mesh-controller.delivery-plan` — *"The delivery plan of a diffset"*. The glossary: *"ADR 0238 called it the
**change plan**; that word is retired"* — and the walk replaces the release plan: *"the controller's
plan is now the walk of one delivery's trunk commit across machines"*.
- `mesh-controller.bus` — *"The bus as a **planned step**"*.
*Release plan* appears in seven files created on or after 2026-10-06, the day ADR 0239 replaced it; *change
plan* in four, including the title of ADR 0238.
**Proposed words** (Change and delivery, except the first):
| Concept | Word | Retire |
|---|---|---|
| what the controller would send one machine | **declaration** (Placement); the verb `plan` becomes `declaration` or `preview` | *plan* for this |
| what a delivery would do to the mesh | **delivery plan** | *change plan*, *release plan* |
| the controller's sending of one commit's builds across machines | **walk** | *release plan*, *plan* for this |
| a step a person starts | **planned step**, or simply *step* | — |
Bare *plan* is then never a mesh concept on its own.
### 4. *push*, *send*, *roll out*, *release*, *deploy*, *upgrade* — moving a change (synonyms and homonyms)
**Evidence.**
- **push.** `mesh-controller.push`: *"Send one machine everything it should be. With no machine named it
is a push of the WHOLE mesh"*. ADR 0221's title: *"a push sends no build a policy or a plan holds
back"*. The same word is a git push (which, per the conventions, starts the build) and a phone
notification (*"Matrix once its push is measured"*, to-be 46). *Push* is in 84 decision records and
152 issues.
- **send.** ADR 0236: *"that machine is sent it, by the ordinary send"*; the gate is judged *"from the
moment it was sent the build"*. The controller's own description of `push` uses *send*.
- **roll out.** ADR 0236's title says *rolls out unattended*; its person-facing verb is `upgrade <module>
roll-out`, and the manifest's policy for the same thing is spelt `roll`. ADR 0218: *"code rolls out one
machine first"*. *Roll out* is in 30 decision records and 36 issues.
- **release.** `mesh-delivery.release` — *"A person's word that a held delivery goes on"*;
`anthropic-licence-manager.release` — *"Unbind a consumer"*; and the retired *release plan* (clash 3).
- **deploy.** The glossary: a delivery is *"not 'deployment' (one stage of it)"*; its *delivery plan* holds
a *deploy plan*. The controller has no verb named deploy, and the only tool that says *deploy* is a
wrapped flow editor's.
- **upgrade.** `mesh-controller.upgrade` — *"How each module's new builds reach its machines … rolled out
one machine first"*: a **policy**, not an act.
**Proposed words:**
| Concept | Word | Domain |
|---|---|---|
| the controller giving one machine its declaration | **send** (the verb `push` asks for one) | Placement |
| one commit travelling to every machine | **delivery** | Change and delivery |
| the per-machine part of a delivery plan | **deploy plan** (kept; the only use of *deploy*) | Change and delivery |
| a build going to its first machine, judged, then the rest | **walk** (the act); **roll** (the policy, one spelling in the manifest and in the verb) | Change and delivery |
| a person letting a held delivery go on | **release** — this sense only | Change and delivery |
| unbinding a licence's consumer | **unbind** (rename the licence verb) | Identity |
| how a module's builds reach its machines | **upgrade policy** | Module |
*Push* is kept for the controller's verb because it names what the operator does, but prose says *send*;
*roll out* becomes the policy's value `roll` and is not a noun.
### 5. *console*, *node tools*, *mesh-console*, *the MCP server* — the operator's surface (synonym and homonym)
**Evidence.**
- The glossary's surfaces section: the console is *"the module (`mesh-console`)"*; not *"the tool bridge",
"the brain" or "the MCP server"*.
- The glossary's operator's machine section: node tools *"replaces 'console' as the module's name;
console remains the word for the person's end of it"*. Both entries are current.
- The live console introduces itself as *"The tools of a Novox mesh, reached as `<machine>.node-tools`"*,
under an MCP server named `mesh`; the organisation's instructions to the agent call that server *"the
console"*.
- *Console* is in 28 decision records; `mesh-console` in 1; *node tools* in 10. A wrapped chat program's
tool and the localisation module's tool use *console* in their own senses (a virtual terminal, a web
console).
**Proposed words:** **node tools** (Core) for the runtime that serves every module's tools and every seat's
verbs on a machine; **console** (Operator and conversation) for the end of it a person or agent uses — the
MCP endpoint and its five tools. `mesh-console` retired. The glossary's surfaces entry is rewritten at
graduation; the contradiction is removed, not annotated.
## Change and delivery
### 6. *gate* — three things (homonym)
ADR 0136 (*a step gates its module, not the machine*): a failing step of a module's apply stops that
module's later steps. ADR 0236: the **gate** on a delivery's first machine — *"healthy three times"*.
ADR 0237 and ADR 0238: the **merge gate**, a status a pull request carries (`mesh/merge-gate`). The
controller's description of `upgrade` says *"judged there at the gate"*; a wrapped forge's tools say
*branch protection*. **Proposed:** *first-machine gate* for ADR 0236's, *merge gate* for the status, and
ADR 0136's becomes *a failed step holds its module* (no noun). Bare *the gate* is not used.
### 7. *ask* — the operator's question, and an entry in the build queue (homonym)
The glossary: *"**ask** — a request for the operator's input, of a declared kind"* (ADR 0234).
`mesh-controller.queue`: *"Every **ask** in the build queue: waiting, in flight … and dead"*;
`.cancel`: *"Drop one waiting or dead ask"*; `.clear`: *"Cancel every waiting ask"*. ADR 0234's ask is in
58 decision records' worth of prose shared with the ordinary verb *to ask*. **Proposed:** *ask* stays the
operator's (Operator and conversation); an entry in the build queue is a **build request** (Change and
delivery), and the three verbs' descriptions say so.
### 8. *tier* — three things (homonym)
The `topic: the tiers` of 36 decision records, and `repos.md`'s *Tier 0 … 3*: the **layers** of the mesh
(node-engine, foundation, controller, surfaces). ADR 0162 and to-be 47: the **steps of a walk**, a module
and the modules built against it (*"its builds are asked and registered tier by tier"*). To-be 46: the
**levels of authority** of an ask (*"a kind below the tier"*, *"no P2 tier before recovery codes"*).
**Proposed:** *layer* for the first (Core); *tier* for the walk's steps (Change and delivery), which is how
the tools use it; *assurance level* for the third (Identity and access).
### 9. *declaration* — what a manifest states, and what the controller sends (homonym)
The glossary: the node-engine *"receives the node's **declaration**"* — the controller's per-machine
document. The glossary again: a module *"**declares** resources"*, and *depends on a seat* is *"derived from
the resources it declares"* — the manifest. `mesh-controller.plan` returns *"the declaration the mesh would
send"*; the container and service-manager tools say *"what its declaration says"*. *Declaration* is in 106
decision records, in both senses. **Proposed:** a manifest **declares** (Module; the noun is *the
manifest*), and what the controller sends one machine is that machine's **declaration** (Placement). The
verb and the noun then never name the same thing, and *declaration* is not used for a manifest.
### 10. *control plane* and *controller* (synonym, drift)
Retired by the glossary on 2026-09-16. Since then: 29 occurrences in 12 to-be designs (`05`, `07`, `08`,
`18`, `19`, `26`, `28`, `29`, `30`, `32`, `33`, `01`) and one as-is design; 73 files created after the
retirement use it — 36 issue reports, 29 decision records, 6 designs — among them issue 156, titled *moving a consumer's delivery subject
stops the control plane*. ADR 0006, the record the word came from, still has it in its
title. **Proposed:** *controller* (Core), as decided; the to-be designs are corrected at graduation, which
is a link-and-word fix the README's rules allow.
## The record and its checks
### 11. *record* — four things (homonym)
A **decision record** (this repository). **The record** of the mesh — the `records` module that agents
search first (*"search the record with the literal text"*), and ADR 0006's *"where the record lives is
deliberately open"*. An **upgrade policy** value: `record` means *register the build, send it nowhere*
(ADR 0236). **On record**: a seat holder the controller has written down (ADR 0223's *"each on record"*).
**Proposed:** *decision record* always qualified in hq; *the record* for the mesh's knowledge base (Operator
and conversation, or a domain of its own if the records module grows one); the policy value renamed
**hold** — which is also what *held* means for a delivery — and *on record* left as ordinary English.
### 12. *check* — at least six things (homonym)
The checks in `00-META/checks/`; a pull request's **merge check**, made of the **merge gate** and the
**repository check** (`mesh/repo-check`, ADR 0238); a delivery group's **composed check**
(`mesh-controller.delivery-check`); the state `checked` of a delivery; the **self-check** (clash 13); a
module's tool named `…_check` (the ssh client's). *Check* is in 92 decision records. **Proposed:** never
bare in a governing document; always one of *merge check*, *repository check*, *composed check*,
*self-check*, *hq check*. The word is too ordinary to retire, so this one is a writing rule, not a list
entry ([04](04-how-the-glossary-is-checked.md) says why it cannot be checked mechanically).
## Health and repair
### 13. *self-check* and *doctor* (synonym)
To-be 45 names the concept **self-check** (8 occurrences) and the verb `doctor` (14); the verb's own
description begins *"The self-check (to-be 45 §4)"*. **Proposed:** *self-check* (Health and repair); the
verb renamed `self-check`, or kept as `doctor` with the glossary mapping it — a verb name is an identifier.
### 14. *condition*, *alert*, *problem*, *incident*, *finding* (synonyms)
The controller's verb `conditions`: *"What is wrong with the mesh now: every open condition"*; to-be 45 says
*condition* 72 times. ADR 0006's observability context owns *"health, logs, metrics, **alerts**"*. ADR
0224's title: *"a provider that keeps failing a consumer is a **problem** the controller reports"*. The
container module's tool is `docker_problems`. *Incident* is in 14 decision records, mostly as *the incident
that earned a rule*. *Finding* is in 19, as what a check or a probe found. **Proposed:** *condition*
(Health and repair) for an open fact about something wrong; *finding* for what one probe or check returned
before it becomes a condition; *incident* kept for a past event that taught a rule (The record); *alert* and
*problem* retired for the mesh's own notion. A wrapped dashboard's *alerts* are its own.
## Connectivity
### 15. *private network* and *overlay* (synonym)
The operator, quoted in ADR 0226 (2026-10-06): *"we simply have a private network"*; ADR 0226's title and
the `mesh-wireguard` module's role: *the private network*, in 37 decision records. To-be 08 (connectivity)
says *overlay* 24 times; *overlay* is in 30 decision records and 13 to-be designs. No tool says either.
**Proposed:** *private network* (Connectivity), the operator's word.
### 16. *packet filter* and *firewall* (synonym)
The seat every machine holds is `node-packet-filter`, with the verb `rules` (*"The packet filter as this
machine enforces it now"*). The nftables module's tool is `firewall_rules`. *Firewall* is in 33 decision
records and 13 to-be designs; *packet filter* in 22 and 12. **Proposed:** *packet filter* (Connectivity),
after the seat; the module's tool renamed `packet_filter_rules` or described in the seat's word.
## Operator and conversation
### 17. *operator*, *the user*, *person* (synonym, partly)
The glossary's *operator account* entry: *"Not 'the user' (ambiguous with a module's own account)"*. *The
user* is in 10 to-be designs, several meaning a bus account (*"the user list"*, the bus server's word), and
in 9 decision records. *Operator* is in 125 decision records and the tools of 23 modules; *person* in 91
records, meaning a human as against the mesh acting unattended (*"a person's word"*). **Proposed:**
*operator* for the person who runs the mesh; *person* for any human acting where the mesh could have; *the
user* retired in the mesh's own prose; a bus account is a *bus account* (Identity). A wrapped program's
*users* are its own.
### 18. *agent* — three things (homonym)
The glossary avoids *agent* for the node-engine because it *"already means two other things here, the build
agent and the operator's coding agent"*. The seat every machine holds is `node-build-agent`, held by the
`build-agent` module; ADR 0237 calls the role *the build seat*, ADR 0236 *the build machine* (14 decision
records). The licence manager's verbs say *"a node's **agent**"* for the coding agent a licence is bound to.
A wrapped memory server has *agents* of its own. **Proposed:** *agent* only for a coding agent (the
operator's, or one the mesh runs); the build role is the **build seat**, its holder on a machine the
**builder** — and the seat renamed when seats are next renamed. *Build machine* retired: any machine may
hold the seat.
## Core
### 19. *store* — four things (homonym)
The glossary: **store** — *"the one postgres server"*. ADR 0189's title, *"The store keeps what the
records name"*, is about the **artifact store**. Research 015 is about the **object store**. ADR 0006: *"a
node reconciles from its own store"* — the machine's local copy of its declaration. **Proposed:** *store*
alone means only the foundation's database server (Core); the others are always *artifact store*, *object
store*, and the machine's *last declaration*.
### 20. The glossary's own contradictions
Recorded in [01](01-the-concepts-in-use.md) and listed here because the fix is the same kind: the console
(clash 5); the deprecated broker, which has no seat in its own entry and claims `mesh-broker` in the entry
for *claim*; and node tools described with the retired *the host*. **Proposed:** fixed at graduation,
in the same change that reorganises the glossary ([05](05-a-glossary-by-domain.md)).
## Checked and found consistent
Worth saying, so nobody repeats the search:
- **catalogue / catalog.** Prose says *catalogue* everywhere (no bare *catalog* in any decision, design or
`00-META` file); *catalog* appears only inside identifiers (`mesh-catalog`, `catalog_modules`). That is an
identifier, not a clash.
- **foundation / substrate.** Of the 10 files created since *substrate* was retired that still use it, 9
are decision records and research (allowed to keep their words); the tenth is the glossary, naming it
as retired.
- **flavor.** Retired; two to-be designs (08, 16) still say it, both predating the retirement.
- **operator** in the tools is used consistently for the person, in 28 tools of 23 modules.
@@ -0,0 +1,140 @@
# 04 — How the glossary is checked
[`AGENTS.md`](../../AGENTS.md): *"If a document states a rule about the mesh, it says how that rule is
verified. An unenforced rule is indistinguishable from a wrong one."* The glossary states two rules — *one
name per thing*, and *"a new name for an existing thing lands here first, in the same change that
introduces it in code"* — and nothing verifies either. [03](03-the-clashes.md) is what that costs. This
document describes a check; it does not build one. Building it follows graduation.
## What a word list can and cannot catch
A list of retired words catches **synonyms**: a word the glossary replaced, used again. It cannot catch
**homonyms** — *plan*, *gate*, *tier*, *ask*, *store*, *record*, *check* — because the word is right in one
sense and wrong in another, and telling them apart is reading, not matching. So the proposal has two
parts: a mechanical check for synonyms, and a rule for homonyms that turns each one, once decided, into a
synonym the list can hold (*release plan* → *walk*; *build machine* → *build seat*; `ask` in the build
queue's verbs → *build request*). A homonym left undecided stays a reviewer's job, and the glossary says
so next to the word.
## Part 1 — the list, kept in the glossary
The list of retired words lives **in the glossary, in one fixed form**, so there is one source and no
second file to drift from it. Every entry that retires words ends with one line:
> *Not:* ~~control plane~~, ~~substrate~~
and nothing else in the glossary is struck through. Today the glossary marks retired words three ways
(struck through, *"Replaces x"*, *"Not x"*); graduation moves all of them to this line.
Each struck word may carry a **scope**, in brackets after it, saying where it is retired:
- *(hq)* — in this repository's governing documents only;
- *(tools)* — in the descriptions of the mesh's tools and seat verbs only;
- no scope — both.
The scope exists because some words are wrong in one place and right in the other: *the user* is retired
in hq prose, but a wrapped identity provider's tool must say *user*.
An entry may also carry an **identifier** line naming code that still has the old name, and the issue that
tracks its rename:
> *Identifier until renamed:* `mesh-host` — the repository, binary and service unit (issue NNN)
An identifier is allowed inside a code span (`` `mesh-host` ``) and nowhere else; when the issue resolves,
the line goes and so does the allowance.
## Part 2 — `00-META/checks/words.py`, over this repository
**Reads** the glossary's *Not:* lines and *Identifier* lines. **Scans** the documents that tell somebody
what to do — the same set `records.py` calls *governing*: `00-META/` (the glossary's own *Not:* lines
excluded), `03-DESIGN/01-to-be/`, `AGENTS.md` and `README.md` — plus `03-DESIGN/00-as-is/`, whose words
should be today's even where its facts are yesterday's.
**Fails** on a retired word (of scope *hq* or none) in running prose. It **ignores**:
- code spans and code blocks, which hold identifiers;
- block quotes and text in quotation marks, which quote somebody — the operator, a tool, an older
record — and must quote them faithfully;
- link targets, which are file names, and file names are immutable when they are a decision record's;
- `02-DECISIONS/` entirely, and `01-RESEARCH/` and `04-ISSUES/` written before the check's start date
— records keep their words, and research and issues observe.
**For research and issues written after the start date** the check applies the same rule. An issue report
quoting a log or a tool keeps the quotation; its own prose uses today's words. This is a choice for
graduation, listed in [00](00-overview.md); the reason to include them is that issue reports are the
largest single source of drift found (36 of the 73 files that say *control plane* since its retirement).
**The reverse rule.** A to-be design that defines a word in the glossary's form — a bolded word followed by
a dash at the start of a definition — must find that word as a head word in the glossary. This is how
*"a new name lands here first"* gets checked, and it would have caught *condition*, *healer* and
*data class* arriving in to-be 43 and 45 with no entry.
**The uniqueness rule.** No head word appears in two entries, and no head word is also struck through in
another entry. This would have caught *release*: today a verb of `mesh-delivery`, and the retired *release
plan*.
**What it would fail on today** — the check is proposed because these are real, and per
[`00-META/checks/README.md`](../../00-META/checks/README.md) a check must fail on something real before
it passes:
| Retired word | Where, today |
|---|---|
| control plane | 29 occurrences in 12 to-be designs; one as-is design |
| the host (for the node-engine) | 34 to-be designs, and the glossary's own node tools entry |
| release plan, change plan | to-be 45, 47 |
| flavor | to-be 08, 16 |
| the user | 10 to-be designs (several are a bus account) |
Each is either fixed at graduation or, where it is a quotation, put in quotation marks — which is the
check doing its job, making a quotation look like one.
## Part 3 — the same list, over the catalogue's tool descriptions
An agent meets the mesh's words most often in tool descriptions: 535 module tools and the verbs of 20 seats
today. Nothing compares them with the glossary, and four of them say *the host*.
**Where it runs.** In the catalogue repository's own repository check (`mesh/repo-check`, ADR 0238), which
already reads every changed manifest. A changed manifest's tool descriptions and its seat's verb
descriptions are matched against the *Not:* words of scope *tools* or none. A finding fails the check, as
everything does on the mesh's repositories (a warning blocks a merge the same as a failure there, [issue
293](../../04-ISSUES/293-a-warning-on-a-required-check-blocked-the-merge/00-report.md), so a warning-only mode
is not available and is not proposed).
**Where the list comes from.** The catalogue cannot read this repository at build time without a new
dependency. Two options, for graduation:
1. **A copy, checked.** The catalogue keeps a copy of the list, and `words.py` here fails when the copy in
the catalogue's trunk differs from the glossary. Simple; two repositories change for one word, which is
honest about what a retired word costs.
2. **The list as an artifact.** This repository's merge publishes the list to the artifact store, and the
catalogue's check reads the newest. One source; one more thing that must be up for a catalogue merge.
The first is recommended: it fails loudly in the right place, and adds no runtime dependency.
**Vendor words.** A module wrapping a program describes that program's objects in its own words — an
identity provider's *users*, a media manager's *releases*, a flow editor's *deploy*. The list holds only
the mesh's own retired words, and a word that is also a common vendor word (*user*, *release*, *deploy*)
is never retired with scope *tools*. That keeps the check from crying wolf, which
`00-META/checks/README.md` names as the way a check gets suppressed.
**On the running mesh, later.** The descriptions an agent actually sees are the ones served, which may lag
the catalogue's trunk. A probe in the self-check could compare the controller's `tools` verb with the same
list and raise a condition. That is a second place for the same rule and is not proposed until the first
has run.
## Part 4 — homonyms, by rule
For a word the glossary marks *homonym* (*plan*, *gate*, *tier*, *store*, *record*, *check*, *ask*,
*agent*), the entry lists each sense with its qualified form and its domain, and the writing rule is: **a
homonym is never bare in a governing document; it is always the qualified form.** This is not
machine-checked — a word list cannot tell *the gate* that means a first-machine gate from one that means
the merge gate — and the glossary says so next to the rule, as AGENTS.md requires: *"checked by review;
the reviewer looks for the bare word in the diff."* Where a homonym is resolved by renaming one sense, the
old sense moves to the *Not:* line and becomes mechanical.
## What it costs
- One Python file of the size of `cycle.py`, run by `merge-check.sh`.
- A one-time correction of the governing documents it fails on, made in the graduation change.
- A copy of the list in the catalogue, and a few lines in its repository check.
- One rule for writers: a retired word may be quoted, never used.
@@ -0,0 +1,101 @@
# 05 — A glossary by domain
How [`00-META/glossary.md`](../../00-META/glossary.md) would be organised once the domains in
[02](02-the-domains.md) are decided. Described only: the glossary changes in the graduation change, with
the decision record that adopts the domains, and not before.
## What is wrong with its shape today
- **Its sections are a history, not a map.** *The operator's machine* holds node tools, bundle, kept region
and installed / holding because they arrived with research 018, not because they belong together; node
tools is a Core concept and kept region a Module one.
- **It has no home for a new word.** A writer adding *condition* would not know which section it goes in,
and so it went in none.
- **Retired words are marked three ways**, so no program can read them ([04](04-how-the-glossary-is-checked.md)).
- **It is one page for 33 entries**; with the roughly 50 words [01](01-the-concepts-in-use.md) found
missing, it is about 85, and one flat list of 85 is read by nobody.
## The proposed shape
One file, as now — it stays the single authority, and a reader searches one page. Its sections are the
domains, in the order of [02](02-the-domains.md) (most upstream first), preceded by two short sections.
### 1. How to read this page
What a domain is, in two sentences; that a word is defined once, in the domain that owns it; that other
domains may use it but not change it; and the forms of the *Not:* and *Identifier* lines the check reads.
### 2. Words every domain uses
A short **shared kernel** — the handful of words with one meaning everywhere, owned by no single domain
and changed only by a decision record: *mesh*, *machine* (or *node*, clash 1), *module*, *seat*, *operator*,
*person*. Each points to the domain where its detail lives. Kept deliberately short: a word that only one
domain refines belongs to that domain.
### 3. One section per domain
Each section opens with a fixed header, then its entries:
- **Purpose** — one sentence, from [02](02-the-domains.md).
- **Owned by** — the context, module or seat that records its concepts (*the controller's `inventory`
context*; *the `mesh-delivery` module*; *every machine's `node-backup` seat*).
- **Upstream of / downstream of** — the domains, by name.
- **Decided by** — the decision records that define the domain's words, so a reader can go from a word
to why.
Each **entry** keeps today's prose form and gains at most three fixed lines:
- the definition, in plain sentences, with its record cited (as now);
- *Not:* ~~retired word~~, ~~another~~ *(scope)* — the words it replaced;
- *Identifier until renamed:* `old-name` — what code still carries the old name, and the issue that
tracks the rename.
A word a domain uses but does not own is not repeated there; the domain's section may list it under *Uses*
with a link, so a reader of one section sees its whole vocabulary without the definition being copied.
### 4. Homonyms
One section at the end for the words that mean different things in different domains — *plan*, *gate*,
*tier*, *store*, *record*, *check*, *ask*, *agent*, *release*. For each, a small table: the sense, its
qualified form, the domain that owns it. This is the page a reader lands on from a search for a bare
word, and it says the rule beside the table: never bare in a governing document; checked by review.
### 5. How this page is kept
Today's section, extended: a new word lands here first, in the domain that owns it; a word moves domain
only with a decision record; a retired word goes on a *Not:* line and nowhere else; and `words.py`
checks the three rules it can ([04](04-how-the-glossary-is-checked.md)).
## Where today's entries would go
| Today's entry | Domain |
|---|---|
| node, control-node, operator account | shared kernel (machine); Placement (operator account); Core (control-node) |
| controller, mesh-controller, node-engine, foundation, store, bus, the deprecated broker | Core |
| package, artifact | Change and delivery |
| seat, depends on a seat, installed / holding | Placement |
| claim, provision (as declared), invokes, bundle, kept region | Module |
| provision (as resolved) | Provisioning |
| channel / intake, ask, operator message / input, reference, console | Operator and conversation |
| node tools | Core |
| delivery, delivery group, delivery plan, mesh-delivery, walk | Change and delivery |
| ~~master / slave~~, ~~hub / peer~~, ~~flavor~~ | *Not:* lines on *controller* and *setting* |
And the sections that are empty today and would be filled from the records that already define their
words: **Identity and access** (credential, secret, vault, grant, bus account, licence, binding, proof —
ADRs 0143, 0183, 0225, 0234); **Health and repair** (condition, probe, signal, watchdog, self-check, healer,
drill, hand-act — to-be 45, ADRs 0227, 0231, 0240); **Data** (data class, backup, restore point, stream
snapshot — to-be 43, ADRs 0233, 0235); **Connectivity** (private network, resolver, uplink, packet
filter, reach — ADRs 0117, 0138, 0223, 0226).
## What derives from it
- **The decision index.** The records' `topic:` (*the mesh*, *the tiers*, *what runs on it*, *building
it*, *checking it*, *how we work*) predates the domains and partly overlaps them. Whether a record also
names its domain — a new frontmatter field, and so a schema change — is left for graduation. It would let
`words.py` scope a word by domain, and `index.py` print the reading order per domain.
- **The constitution page.** The mesh injects a page derived from `how-we-build.md` into design sessions
(playbook 05). A derived glossary page per domain could be injected the same way, so an agent starts
with the words; that is a separate decision, not proposed here.
- **Tool descriptions.** Once a domain owns a word, a module describing that concept in a tool uses it; the
check in [04](04-how-the-glossary-is-checked.md) holds them to the retired half, and review to the rest.
@@ -0,0 +1,85 @@
---
status: active
initiated: 2026-10-08
touches:
- 02-DECISIONS/0189-the-store-keeps-what-the-records-name.md
- 02-DECISIONS/0251-the-registries-say-what-they-hold-and-keep-what-is-named-and-a-machine-keeps-the-image-it-runs-and-the-one-before.md
- 03-DESIGN/01-to-be/51-the-registries-keep-what-is-named.md
- the artifact store, the catalogue's modules that pin an image by digest
---
# 035 — The images from before the mesh
## What is investigated
The artifact store's tools ([ADR 0251](../../02-DECISIONS/0251-the-registries-say-what-they-hold-and-keep-what-is-named-and-a-machine-keeps-the-image-it-runs-and-the-one-before.md))
counted 469 unrecorded manifests marking 23.8 GiB on 2026-10-08. 374 of them are copied bases
([issue 321](../../04-ISSUES/321-a-copied-base-was-recorded-by-nothing-and-a-let-go-index-kept-its-platforms/00-report.md),
[ADR 0257](../../02-DECISIONS/0257-a-copied-base-is-recorded-where-it-is-copied-kept-by-the-builds-that-stood-on-it-and-held-once-per-image.md)).
Most of the rest are images pushed to the same store before the mesh existed. The predecessor's
pipeline built them, under a namespace per owner, one repository per app.
No record of the mesh names these images, so [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md)
§3 keeps every tool away from them. That is correct: the mesh did not make them. It also means they stay
until a person decides. This effort finds out, for each one, whether anything still uses it, and
recommends what to do. Nothing was deleted.
## How it was measured
All of this was read, nothing was written. On 2026-10-08:
- **The store's view.** `artifact_store_references` and `artifact_store_repositories` give each
repository's manifests, tags and sizes. They give no dates.
- **What each machine declares.** The controller's `images` verb answers for each of the four machines.
- **What each machine runs.** The container runtime module's container list answers on each machine.
- **What still names them.** I searched every repository on the forge for each image's path and digest.
There are 50 repositories, and I read 31 of them closely: both catalogues, the apps' own repositories,
the setup repositories and the predecessor's.
- **Dates.** No tool gives a manifest's push time. A date below is either an image's `created` date, from
a copy still in a machine's local image store and matched by tag, or the date of the commit the tag
names. Where neither exists, the date is not known, and the table says so.
## What was found
[01-the-images.md](01-the-images.md) has the table. In summary:
- **3 repositories hold an image a machine runs today.** Each is one of three app images on the anchor:
an invoicing app's API, its web front, and a static company website. A catalogue module pins each by
digest, under an older name of the same store. Each repository also holds two older tags that
nothing uses.
- **2 repositories are pinned only by catalogue modules that no machine is assigned.** One is a
magazine site's older image. The other is an older photo client, pinned by two per-person photo
modules. Both are stale definitions.
- **35 repositories, plus 2 single manifests in repositories the mesh builds, are used by nothing.**
Nothing runs them, declares them or pins them.
- Most were superseded by a module that builds its own image of the same app.
- The rest are abandoned: a trading bot, a chatbot, test images, the predecessor's dashboard and flow
engine, and the bootstrap's first builder and controller images.
- Together they mark between 5.9 and 7.5 GiB that nothing else marks. The two bounds depend on how the
blobs they share among themselves are counted.
**A finding beside the question.** The store's tools classify the three running images as
*unrecorded*, the same word they use for an image nothing uses. The reason is that the catalogue pins
them under an older name of the store, and the mesh did not make them. Nothing removes an unrecorded
manifest today, so nothing is at risk. But any later decision to let unrecorded images go by some
other rule would take running images with it. That is recorded as
[issue 323](../../04-ISSUES/323-an-image-a-definition-pins-under-an-older-name-of-the-store-reads-as-unrecorded/00-report.md).
## What is recommended
- **Keep** the three running images. Point their modules at an image the mesh builds, where one exists,
so the running images become the mesh's own. Two of the three apps already have a build of their own
on a branch.
- **Delete the two stale catalogue modules**, then treat their images like the rest.
- **Let go of the 35 repositories and 2 manifests** once a decision says how an image the mesh did not
make may be removed. ADR 0189 §3 forbids every tool from doing it today, and that is intended. The
decision is the operator's: the images are the operator's, and some can no longer be rebuilt.
- **Adopt none of them as a module.** Every app in use already has a module.
## Open questions
- Should an image the mesh did not make be removed through the controller's records, by a person
recording it (as ADR 0257 lets a person record a copied base), or through a separate verb of the
store's tools?
- Should the older tags in a repository that is in use (about 0.8 GB together) go before their modules
are moved to the mesh's own build, or with it?
@@ -0,0 +1,64 @@
# 035 — The images, one by one
Read on 2026-10-08. Sizes are the bytes a repository's manifests mark. *Only theirs* is what no other
manifest in the store marks, which is what removing the repository would free. A machine is named by its
role.
Dates: *created* is an image's build date, from a copy in a machine's local image store matched by tag;
*commit* is the date of the commit the tag names; *not known* means neither was found.
## In use
| what | size | manifests | newest | in use by | named by | recommendation |
|---|---|---|---|---|---|---|
| an invoicing app's API | 1.41 GB | 3 tags | created 2023-01 | the anchor, its API container (newest tag, matched by digest) | its catalogue module, pinned under an older name of the store; also the frozen runtime base of the app's own build on a branch | keep the newest; let the two older go (about 0.7 GB); move the module to the mesh's own build |
| the same app's web front | 60.5 MB | 3 tags | created 2023-01 | the anchor, its web container (matched by digest) | its catalogue module, likewise | keep the newest; let the two older go |
| a static company website | 474 MB | 3 tags | created 2022-08 | the anchor, the website container (matched by digest) | its catalogue module, likewise; the site's repository has its own build on a branch, not yet delivered | keep the newest; let the two older go (about 75 MB) |
## Pinned only by a stale module
| what | size | manifests | newest | in use by | named by | recommendation |
|---|---|---|---|---|---|---|
| a magazine site, its older image | 393 MB | 3 tags | created 2025-10 | nothing: the anchor runs the mesh-built image | a catalogue module whose definition no machine uses | let the two older go; the newest once the stale module is deleted |
| a photo app's older client | 11 MB | 1 | created 2024-10 | nothing: the anchor runs the mesh-built client | two per-person photo modules, assigned to no machine | delete the two modules, then let it go |
## Used by nothing
Each row is one repository, unless it says otherwise.
| what | size (only theirs) | manifests | newest | superseded by, or why abandoned | recommendation |
|---|---|---|---|---|---|
| a car-listing scraper | 2.31 GB (1.43 GB) | 3 | commit 2026-02 | the module's own build, which the home server runs | let go |
| a chatbot's API | 3.04 GB | 1 | before 2024-09 | abandoned since 2024 | let go |
| a chatbot's web front | 19.8 MB | 1 | before 2024-09 | abandoned | let go |
| a comic app's API and web front (2 repositories) | 828 MB | 1 each | created 2026-09 | the predecessor's; no module | let go, unless the app is wanted back (then build it as a module) |
| a photo app's earlier API and web front (2 repositories) | 519 MB | 4 each | not known | the photo modules | let go |
| a photo app's server and admin client (2 repositories) | 445 MB | 1 each | created 2024-10 | the photo modules' own builds | let go |
| a map game's API and web front (2 repositories) | 121 MB | 3 each | commit 2026-02 | renamed to the travel game below | let go |
| a travel game's API and web front (2 repositories) | 104 MB | 2 each | commit 2026-02 | no containers or data remain | let go (it can be rebuilt from its repository) |
| a racing-circuit site | 264 MB | 3 | created 2026-06 | the module's own build, which the home server runs | let go |
| an older company website | 400 MB | 1 | not known | the static website above, and its own build | let go |
| a payment-link site | 52.6 MB | 3 | before 2025-11 | the module's own build | let go |
| a mail-forwarding service | 58.5 MB | 1 | created 2026-03 | its repository's own module, assigned nowhere | let go |
| a snake game and a text game (2 repositories) | 88 MB (0.8 MB) | 1 each | created 2026-08 and 2026-09 | the modules' own builds | let go |
| a trading bot | 85 MB | 3 | commit 2022-08 | abandoned | let go |
| a trading bot's scheduled jobs and web front (2 repositories) | 83 MB | 1 each | not known | abandoned (setup last changed 2025-01) | let go |
| three base images of a former owner namespace (3 repositories) | 188 MB | 1 each | not known | abandoned (setup last changed 2023-05) | let go |
| test images (3 repositories) and a capability test | 135 MB | 1 to 3 | not known | tests of the predecessor | let go |
| the predecessor's dashboard and flow-engine API (2 repositories) | 165 MB (157 MB) | 2 and 3 | created 2026-09 | the predecessor is retired | let go |
| the bootstrap's first builder and controller images (2 repositories) | 30 MB (26 MB) | 1 each | created 2026-09 | the build agent; the controller runs as a bundle | let go |
| a build agent's and the controller's former server images (2 repositories) | 62 MB (55 MB) | 2 each | created 2026-09 | the same | let go |
| one manifest in the mesh-built tools image's repository | 87 MB (6.6 MB) | 1 of 4 | not known | the three recorded beside it | let go |
| one manifest in the route proxy's server repository | 9.9 MB (6.0 MB) | 1 of 6 | not known | the kept build the machines declare | let go |
**Together**, the rows used by nothing mark between 5.9 GiB and 7.5 GiB that nothing outside them marks.
The upper bound sums each repository's own bytes. That counts twice a blob two of them share. The lower
bound subtracts every shared blob, and that also subtracts blobs shared only among these rows, which
removing all of them would free. Removing the two stale modules' images adds 0.4 GiB. Removing the older
tags of the three images in use adds about 0.8 GB.
**Not determined.**
- A push date for 14 of the repositories.
- Whether the forge listing of 50 repositories is all of them. A second page came back empty, so I think
it is.
@@ -0,0 +1,130 @@
---
status: active
initiated: 2026-10-08
touches:
- the bus
- 03-DESIGN/01-to-be/25-the-bus-on-nats.md
- 03-DESIGN/01-to-be/32-what-a-module-declares.md
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
- 03-DESIGN/01-to-be/46-the-conversation-with-the-operator.md
- 03-DESIGN/00-as-is/14-a-modules-state.md
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
- the controller's records
- the catalogue's module manifests
became: []
---
# 036 — Everything moves over NATS
## What is investigated
This effort asks two questions:
1. Where do the mesh's modules still talk to each other around the bus?
2. What does a **view** onto the bus need? A view is a web app or a terminal UI built from the bus
alone. *View* is proposed as a glossary word for this.
The operator set three principles on 2026-10-08:
1. **All communication between modules goes over NATS.** A module may keep its own state. Another
module reading its files, calling it over HTTP or a socket, sharing env files, running `docker exec`
in its container, or using ssh is a gap. Only a channel's outside link, an external API, leaves the
bus.
2. **The approval flow, for a person's decisions, is NATS-native and generic.** Another effort is
designing it. This one lists only how it fits ([evidence §6](01-evidence.md#6-how-the-approval-flow-fits)).
3. **Views are built from the bus.** State lives in key-value buckets, with events that say what
changed, and history lives in streams. A browser reaches the bus by NATS WebSocket and an auth
callout. Every module's important state should therefore be watchable on the bus.
## Why
[Design 25](../../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1 already says "the bus is where the mesh
happens", and that conditions and observed state belong in buckets anything may watch. Two parts of
that are now built:
- **Buckets for a module's state** ([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)).
- **The controller's conditions**, kept on the bus.
This effort measures how far the rest is from the principles, so that the next decisions start from
evidence.
## What was found
The detail is in [01-evidence.md](01-evidence.md), whose gap table is §5. The target state is in
[02-utopia.md](02-utopia.md). These are the gaps, ranked by how much they stand between the mesh and
the three principles:
1. **Most of the controller's state cannot be watched (G2).**
- Plans, builds, artifacts, node facts, assignments, settings, data items, heals, durations and
licences live in the controller's databases.
- They can be read only by calling a verb again and again.
- Settings change without any event, and `settings_history` holds 0 rows.
- Only the conditions follow the bucket-plus-event pattern a view needs, and they are the template
for the rest.
2. **Nothing outside the private network can reach the bus as a person (G1).**
- There is no WebSocket listener and no auth callout.
- There is one account of password users, composed into a static file and reloaded by signal.
- No grant is shaped for a view.
- Until this exists, no view can be built from the bus.
3. **The bus is one server with one copy of everything (G3).**
- There is no cluster and no leaf node, and every stream and bucket has one replica.
- A restart of the control-node stops every tool call, declaration and event in the mesh.
- A laptop away from the private network has no bus at all, even between its own modules.
4. **Modules reach each other through side doors (G5).**
- `artifact-store-tools` runs `docker exec` in the registry's container.
- The backup holder dumps stores by `docker exec`.
- Two modules hold the container runtime's socket.
- forticlient calls the resolver's verbs over a unix socket.
- route-adapter writes into the proxy's directory.
- Eight media modules carry copied code that calls other apps' HTTP APIs.
5. **Some mesh state and addresses live off the bus (G4).**
- The controller's facts snapshot is kept as a tag in the artifact store and read over HTTP.
- The lab is given the forge's address as a hand-written setting.
- What the registry holds is never announced.
- The bytes of artifacts and git repositories are a reasonable named exception. Their index is not.
6. **The bus does not watch itself (G7).** There is no system account and no dead-letter stream,
although design 25 promises one. These live numbers are unexplained and nothing raises them:
- 2 slow consumers;
- 4 688 JetStream API errors;
- 4 407 connections in 39 hours, against 16 open;
- about 40 writes a second to a delivery bucket of 998 keys.
*2026-10-08:* measured again and registered as issues
[326](../../04-ISSUES/326-mesh-delivery-writes-a-delivery-whether-or-not-it-changed/00-report.md) (the
delivery bucket's writes),
[327](../../04-ISSUES/327-every-controller-verb-opens-its-own-bus-connection-so-the-connection-count-hides-a-reconnect-loop/00-report.md)
(the connections), [328](../../04-ISSUES/328-jetstream-api-errors-are-counted-and-attributed-to-nothing/00-report.md)
(the API errors), [329](../../04-ISSUES/329-an-event-consumer-a-module-stops-declaring-stays-on-the-bus/00-report.md)
(the stale consumers) and [330](../../04-ISSUES/330-the-dead-letter-stream-design-25-promises-does-not-exist/00-report.md)
(the dead-letter stream). The slow consumers are recorded under
[issue 270](../../04-ISSUES/270-phase-1-watches-signals-that-come-later-and-hears-only-the-controllers-faults/00-report.md),
whose open question 2 they share.
7. **Smaller gaps:**
- Modules' own databases are invisible to views (G6).
- Buckets keep one value and events last a week, so history is short (G8).
- Lists asked of verbs meet the 1 MiB message limit (G9).
- The controller keeps two unused HTTP ports (G10).
**Much already passes the test.** The forge is wrapped by its module, and pull requests arrive as
events. No module calls the vault. No module uses ssh to reach another. Apps using a database or an
API through a provision are acceptable by the principle.
## What it touches
- **The bus's design and server configuration:** accounts, WebSocket, the callout, leaf nodes,
replicas and the system account.
- **The controller's records**, and which of them it mirrors to buckets and events.
- **What a module declares:** state, history streams, and tools in place of side doors.
- **The conversation with the operator**, where the approval flow meets a view.
## Open questions for the next step
1. **Order.** Which comes first: G2 for the controller, which makes views possible, or G1, which
makes them reachable? This effort suggests G2. It is useful to the mesh's own modules without any
browser.
2. **Accounts for people.** Does a person's view get its own NATS account, with imports from the
mesh account, or stay a user in the one account under a narrower grant? Design 25 §4 chose one
account when only modules were users.
3. **Which module serves the auth callout,** and which login it trusts: the mesh's identity provider,
or the proof of to-be 46 §10.
4. **Whether leaf nodes come before or after a cluster.** The laptop argues for leaf nodes first.
@@ -0,0 +1,330 @@
# 036 — Evidence: where the mesh still talks around the bus
Measured on 2026-10-08, read-only. Three sources were used:
- **The code.** The catalogue (116 module manifests and their code), the media catalogue (12), the
controller, the node-engine, the tool runner and the SDK, all at their trunk heads of that day.
- **The running bus.** It was read through the `nats` module's tools (`nats_server`, `nats_streams`,
`nats_buckets`, `nats_users`, `nats_connections`, `nats_subscriptions`, `nats_backlog`).
- **The running store.** It was read through `mesh-store.databases` and `mesh-store.query`, for table
names and row counts only.
No machine was logged into and nothing was changed.
The test applied throughout is the operator's first principle. **A module may keep its own state, but
another module reaches it only over the bus.** A module that reads another's files, calls it over HTTP
or a socket, shares an env file with it, runs `docker exec` in its container or uses ssh to reach it is
a gap. Only a channel's outside link, an external API, leaves the bus.
## 1. What is already on the bus
This section is the baseline, because most of the mesh already passes the test.
| fact | measured |
|---|---|
| server | one NATS server, release 2.11, on the control-node; 16 connections at the time of reading |
| streams | 21: CONTROL (work queue), NODES and ASSIGNMENTS (last per subject), EVENTS (7 days, 10 000 per subject), two seat work queues, and 15 key-value buckets |
| EVENTS | 183 205 messages, 143 MB, 5 260 distinct subjects, 19 durable consumers |
| key-value buckets | 15, owned by 5 modules: the controller (`calls`, `conditions`, `condition-history`, `hand-acts`, `lease`), mesh-delivery (`deliveries`, `groups`), the messenger (`open`, `sent`), claude-code (`config`, `holdings`, `servers`) and the licence manager (`bindings`), plus a `cancelled` bucket per build seat |
| events declared | 36 of 128 modules declare `emits`; 16 declare `consumes` |
| tool calls | every module tool and seat verb is core request/reply, `mesh.mod.<module>.tool.<tool>` and `mesh.seat.<seat>.tool.<verb>`, announced in the NATS services protocol (ADR 0197) |
| the forge's own state | the `gitea` module wraps the forge. Pull requests become events (`pull.merged`, `pull.updated`, `pull.closed`), and commit statuses are its tools. mesh-delivery reaches the forge only through those tools |
| vault | no module calls the vault. The node-engine unseals a secret into a file, and the vault's export travels in its node's declaration |
| ssh between modules | none. The ssh-client and claude-code modules only parse configuration (`ssh -G`) |
The controller's conditions already live on the bus. They are kept in `mesh-controller_conditions`
(802 keys) and `mesh-controller_condition-history` (90 days), and each change is announced as
`condition-raised`, `condition-changed` or `condition-cleared`. **They are the one complete example
of the pattern a view needs**: current state in a bucket, plus an event that says what changed.
## 2. Communication that leaves the bus
### 2.1 Acceptable by the principle
These are an app using a provision the way it would use any database or API, or the mesh talking to
the world outside:
- **Apps on their own provisions** (about 16 modules). Postgres serves baserow, gitea, keycloak, letta,
mailu, n8n, nextcloud and umami. The other provisions in use are mongo, S3, influx, MQTT, OIDC and
local model access, each through `${bound:<provision>:at}` and `:port`.
- **The route data plane.** About 30 modules are forwarded by the proxy over HTTP.
- **Desktop modules on the display server's socket.** Fifteen modules use it, and it never leaves its
machine.
- **Media apps sharing a library.** Nine modules share the media library directories.
- **External APIs** reached by a channel holder (the messenger's chat service) and by the licence
manager (the model vendor).
### 2.2 Gaps
The gaps are numbered for the table in §5.
**Docker into another module's container, and the container runtime's socket (G5).**
- **`artifact-store-tools`** runs `docker exec` inside the registry's container, which belongs to the
`distribution` module, to read what the registry holds.
- **The `nats` module's own tools** read `accounts.conf` with `docker exec … cat`, and fall back to
`docker exec … wget` when the monitoring port does not answer. These calls stay inside the module,
so they are not a gap.
- **The backup holder (restic)** runs each module's declared `dump` command. For postgres, mongodb,
mssql, nats and supabase, that command is a `docker exec` into the module's container. The command
is the owning module's own declaration, so the module still chooses what is read. The channel is
still a side door, though: the backup holder needs the container runtime's whole power over every
container.
- **The node-engine at genesis** reaches the controller with `docker exec <controller> mesh-controller
<subcommand>` and `docker cp`. It seeds gitea's role in the store with `docker exec <store> psql`,
and it reads the store's and the deprecated broker's administrator secrets from their files.
- **Two modules mount the container runtime's socket:** `build-agent` (read-write) and `supabase`
(read-only). The merge gate's check also mounts it into its check container. Anything holding that
socket can act as any other module's container.
**A unix socket and a shared directory between modules (G5).**
- **forticlient** dials the systemd-resolved module's verb socket to route its domains. That is a
module calling another module's verb without the bus.
- **route-adapter** writes route files into the proxy's (traefik's) watched directory.
**Mesh-written code calling another app's HTTP API (G5).**
- **sonarr, radarr, lidarr and bookshelf** carry identical provisioner code that calls nzbget's,
qbittorrent's and jackett's HTTP APIs to check a connection.
- **The plex API** is called from the tools of sonarr, radarr, lidarr, tautulli and ombi.
- **Status endpoints of the other apps** are called by ombi and bazarr, and home-assistant calls
sonarr's, radarr's and lidarr's.
These are eight modules in all. Each check is a question that the called module could answer as a tool
of its own. Today the code that asks it is copied into each caller.
**The artifact store over HTTP (G4).**
- **The controller** speaks the OCI registry's HTTP API to hold, tag, sweep and delete artifacts.
- **The builder** pushes images and archives, and mirrors upstream images.
- **The node-engine** fetches bundles and archives by URL.
- **The docker module** lists the registry, by address, as an insecure registry.
This traffic is bytes by digest, which a 1 MiB message is not shaped for. Two parts of it are state
rather than bytes, though:
- **The facts snapshot** is the controller's composed record of what the mesh is, which the merge check
judges against. It is kept as a document under an OCI tag in the registry, written and read over
HTTP (to-be 45 §9). It is the controller's state, and it lives in another module's store.
- **What the registry holds** is learnt by `docker exec` (above) or over its HTTP API. Nothing
announces it on the bus.
**The forge reached by address (G4).**
- **The builder, the `records` module and the `lab` module** run `git clone` and `git fetch` against
the forge. The lab is given the forge's address as a setting (`${setting:forge}`) rather than a
provision.
- **The node-engine at genesis** uses the forge's admin HTTP API to create its organisation, team and
user.
- **The builder's package registry** is the forge's npm endpoint, given as a binding.
Git objects are bytes, like artifacts. The lab's setting, however, is an address written by hand,
which is what provisions exist to replace.
**The certificate authority over HTTPS.** The proxy and `ca-trust` reach step-ca's ACME directory and
root certificates by `${bound:internal-acme-ca:…}`. ACME is the protocol the proxy speaks, so this
belongs with §2.1. It is listed here because the trust roots are a mesh fact that could be announced
instead.
**The MCP endpoint over loopback HTTP.** claude-code points the agent at the tool runner's loopback
port. The agent speaks MCP over HTTP, and the tool runner is the bridge onto the bus, so this is the
designed entrance (the mesh MCP server) and not a gap.
**Unused HTTP surfaces in the controller (G10).** The controller still listens on two loopback ports:
- An `api` port, whose authentication is a stub that refuses every request.
- A `board` port.
Neither appears in any design as the way to read the mesh. A view built from the bus replaces both.
**Mesh tool code on postgres (G6).**
- **The controller** opens its three databases (`inventory`, `identity`, `licences`). The merge gate
also creates and drops throwaway databases with an administrator credential.
- **Four modules' tool code** opens its own database directly: `mesh-catalog`, `model-usage`,
`claude-licence-manager` and `showcase`.
Each reads only its own database, which the principle allows. The gap is not the connection. It is
that state kept this way is invisible to anything else except through a verb (§3).
`mesh-store.query` runs one read-only statement against any database the store holds. A future view
could be tempted by it. It is an operator's diagnostic, and it would bypass every module's ownership of
its own data.
## 3. State a view could not watch
A view, in the operator's third principle, reads current state from a bucket, learns that it changed
from an event, and reads history from a stream. The table below says which of the mesh's important
state allows that today.
| state | where it lives | event on change | bucket | otherwise |
|---|---|---|---|---|
| conditions | bus only | raised, changed, cleared | `conditions`, `condition-history` | — |
| hand-acts | bus only | **none** | `hand-acts` | — |
| calls the controller answered | bus only | none | `calls` (2 000 kept, 14 days) | — |
| the lease and epoch | bus and `controller_epoch` | none | `lease` | — |
| deliveries and delivery groups | bus only (mesh-delivery) | `transition`, `group` | `deliveries`, `groups` | — |
| what the operator was told | bus only (messenger) | `refused` | `open`, `sent` | — |
| licence bindings | licence manager's database, mirrored | adopted, refused, failing, usage read | `bindings` | — |
| **plans and walks** | `release_plan` (456 rows) | `plan-moved` | **none** | `plans`, `delivery-walks` verbs |
| **builds and artifacts** | `build` (7 193), `build_gate`, `artifact_collected` (2 312) | `built` (from the build seat), `rolled-back` | **none** | `builds`, `artifacts` verbs |
| **nodes: facts, reports, health** | `node`, `node_report`, `node_health`, `tunnel_peer`, `taken`; the facts snapshot in the registry | `applied` and `refused` per report | **none** | `nodes`, `node`, `status` verbs |
| **assignments, modules, seats** | `assignment` (244), `module` (133), `seat`, `seat_holding`, `port_assignment`, `provision_pin`, `binding` | none; a membership per assignment is kept in the ASSIGNMENTS stream | **none** (ASSIGNMENTS is last-per-subject, but has no event) | `modules`, `seats`, `node` verbs |
| **settings** | `settings` (68), `settings_history` (0 rows) | **none** | **none** | `settings` verb |
| **data items and their readings** | `data_item` (124), `data_reading` (602) | none | none | `data` verb |
| **heals** | `heal` (28) | `healer-acted` | none | `healers` verb |
| **durations** | `duration` (14 617) | none | none | `durations` verb |
| **licences, usage, audit** | licence manager's database: `licence`, `usage`, `audit`, `lease`, `offered` | adopted, failing, usage read | only `bindings` | `licences`, `usage` verbs |
| **credentials and grants** | `bus_user` (76), `secret` (143), `module_secret` (121), `enrolment_token`, `person` | `secret-replaced` only | none | `issue`, `rotate`, `token` verbs |
| **the bus's own health** | the server's monitoring port, on loopback | none | none | the `nats` module's tools |
The most watched state is the open conditions, and it is the one example that is complete. The state
a view of the mesh would show first is what each machine runs, how far a delivery has got, and what is
being built. Each of these can be learnt only by calling a verb again and again. A verb's answer is
also bounded by the server's message limit (§4.4).
**Settings change with no trace.** `settings_history` has 0 rows, and no event is published when a
setting changes. A view cannot show who changed a setting or when, and neither can the controller.
**hand-acts have a bucket but no event.** A view can watch the bucket's keys, but other modules cannot
consume a hand-act through the EVENTS stream the way they consume a condition.
## 4. The bus itself
### 4.1 Accounts, grants and credentials
- **Accounts and users.** The bus has one account (`MESH`), and its users hold passwords. The
controller mints each password and seals it to the recipient. It keeps a bcrypt hash and composes
every user, with explicit publish and subscribe lists and `allow_responses` (one answer, one
minute), into `accounts.conf`.
- **Reload.** The node-engine writes that file into the `nats` module's directory. The module's
entrypoint checks the file's digest every five seconds and sends the server SIGHUP when it changes.
- **What is not configured.** The code has no nkeys, no JWT accounts, no operator and no auth callout.
Design 25 §4 rejected the JWT model on purpose for a single tenant.
- **20 bus accounts were live.** There are three per machine (`node.<machine>`, `<machine>.node-tools`
and `<machine>.build-agent`), plus the controller and the control-node's own module users.
- **The grants are wide.** The tool runner on one machine carries the union of every module assigned
there: up to 170 publish and 599 subscribe grants. What keeps one module's code from acting as
another's on the same machine is the tool runner, not the server.
- **People.** A person's bus account exists in the design (design 25 §7), and the `person` table has
0 rows. The operator reaches the bus through the mesh MCP server, which uses the machine's tool
runner account.
**What a view in a browser would need, and is missing:**
- **A WebSocket listener.** None is configured. The server configuration has `port`, `http`, `tls`,
`jetstream` and `include` and nothing else.
- **A way to admit a person** with a short-lived credential and a grant derived from what that person
may see. The NATS mechanism for this is the auth callout, and it is not configured. Without it, each
browser would need a password in the static file, and the file would be rewritten and reloaded for
each login.
- **A grant shape for a view:** read a set of buckets and subscribe to a set of event subjects. No such
grant is composed today. The nearest are the module state grants of ADR 0201.
### 4.2 One server, one copy
- **No cluster, no leaf nodes, no replicas.** The server runs with none of these. No stream or bucket
sets replicas, so every one has a single copy (the server reports `ha_assets: 0`).
- **The control-node is a single point of failure for every link.** All 16 connections cross the
private network to that one machine, at round trips of 33 to 94 ms from the other machines.
- **What stops when it restarts.** Every tool call, declaration, report and event stops. Design 25
§11 names leaf nodes as "deliberately out of scope; noted so it is not forgotten".
- **Backups.** JetStream state is protected by a nightly snapshot of each stream through the snapshot
API (ADR 0235). The live state between two snapshots has one copy, on one disk.
- **The laptop.** It travels and sleeps behind a VPN client. While it is away from the private network
it has no bus at all, even for tool calls between modules on itself.
### 4.3 Observability
- **No system account.** The bus has none, so nothing hears the server's own events about other
users: slow consumers, permission violations, connections and disconnections. The controller hears
only two JetStream advisories, `CONSUMER.MAX_DELIVERIES` and `CONSUMER.DELETED`.
- **Who reads the monitoring port.** The monitoring port (8222) is published on the control-node's
loopback. Only the `nats` module's tools read it, when asked. Nothing turns what it says into a
condition.
Numbers from the live bus that nothing currently raises:
- **2 slow consumers** since the server started 1 day 14 hours earlier.
- **4 688 JetStream API errors** out of 6.08 million calls.
- **4 407 connections** in those 39 hours, against 16 open. The tool runners' connections were 2 to 39
minutes old when read. *I think* a tool runner reconnects often, because its connection age was far
below the server's uptime; the cause is not yet known. Reading the tool runner's journal for
disconnect reasons would find out.
- **About 5.7 million messages** delivered on the subscription to `$KV.mesh-delivery_deliveries.>` in
those 39 hours. That is about 40 a second, for a bucket of 998 keys. *I think* mesh-delivery rewrites
unchanged deliveries on every pass. Comparing each value's revision with its content would confirm
it. Every view that watches the bucket would receive the same stream.
Two further findings:
- **No dead-letter stream.** Design 25 §3 says a message that exceeds `max-deliver` feeds
`mesh.events.dead`, its own small stream. No such stream exists. The advisory is heard by the
controller and kept nowhere else.
- **Stale consumers.** Four durable consumers, one per machine's claude-code, filter on
`claude-licence-manager` events `licence.rotated` and `licence.switched`. No manifest emits either,
and none of the four has delivered a message. The bindings bucket replaced them (ADR 0201). ADR 0230
leaves deleting a consumer to a person.
### 4.4 The message limit
- **The limit.** `max_payload` is the server default, 1 MiB. No configuration sets it.
- **Issue 314.** Answers above the limit were dropped in the sender (issue 314). Both the controller
and the tool runner now page a large answer. The holder keeps the pages for five minutes, and the
caller asks again with a `Mesh-Page` header.
- **Trimming.** Verbs that grow, such as `conditions`, `status`, the self-check and the registry
listings, are also trimmed.
- **Other ceilings.** A module's state value is capped at 256 KiB and a bucket at 64 MiB. A kept call
is capped at 68 KB.
- **What this means for a view.** A view that reads a bucket per key never meets the limit. A view
that asks a verb for a whole list does.
### 4.5 Retention
- **Buckets keep one value.** Every bucket keeps history 1: the newest value per key, and no earlier
ones.
- **Events last a week.** EVENTS keeps 7 days and at most 10 000 messages per subject. Condition
history keeps 90 days and hand-acts keep 90 days.
- **No longer record.** No stream keeps what happened beyond those windows. A view of "what happened
last month" has the store's tables (builds, plans, durations) and nothing on the bus.
## 5. The gap table
The effort column is a rough size: **S** is a pull request or two, **M** is a design section and a few
pull requests across repositories, and **L** is a decision record, a design and work in several
repositories.
| # | gap | where | evidence | impact | suggested direction | effort |
|---|---|---|---|---|---|---|
| G1 | No way for a browser or a person's client to reach the bus | the `nats` module's server configuration; the controller's account composition | no WebSocket listener; no auth callout; one account of static password users; `person` 0 rows | no view onto the bus can be built; every view today would need a backend that reads verbs | a WebSocket listener behind the proxy; an auth callout served by a mesh module that checks the operator's identity provider and issues a short-lived user with a view-shaped grant | L |
| G2 | Most of the controller's state is not watchable | the controller's `inventory` and `licences` databases | §3: plans, builds, artifacts, nodes, assignments, settings, data, heals, durations, licences have no bucket; settings and assignments have no event | a view must poll verbs; nothing else in the mesh can react to a plan, a node's facts or a setting changing; settings changes leave no trace | for each, a bucket of current state written on change and an event naming what changed, the store remaining the controller's record; conditions are the template | M each, L in total |
| G3 | One bus server, one copy of everything | the `nats` module on the control-node | no cluster, no leaf nodes, replicas 1 everywhere, `ha_assets: 0` | a restart of one machine stops every link in the mesh; the laptop away from the private network has no bus at all | a leaf node per machine, so local traffic and tool calls survive the hub; later a three-node cluster with replicated streams for what must not be lost | L |
| G4 | Mesh state and addresses kept off the bus | the facts snapshot in the registry; the forge address as the lab's setting; the registry's contents | §2.2: the snapshot is an OCI tag over HTTP; `${setting:forge}`; the registry is read by `docker exec` or HTTP | the merge check depends on the registry for the controller's state; a hand-written address does not follow the forge | the facts snapshot in a bucket or JetStream object store; the forge as a provision; the registry announcing what it holds as events. The bytes of artifacts and git stay off the bus as a named exception | S–M |
| G5 | Side doors between modules | `artifact-store-tools`, restic's dumps, the node-engine's genesis, `build-agent` and `supabase` on the container runtime's socket, forticlient's socket, route-adapter's files, 8 media modules' HTTP checks | §2.2 | a module acts on another's container, socket or files with no grant the bus can check and nothing announced; code is copied between modules | each owning module serves the act as a tool (`distribution` lists what it holds, each store module serves its dump, the resolver's verbs over the bus, each download client answers "is this reachable with this key"); the socket kept only where building containers is the module's job | M |
| G6 | State behind a module's own database, seen only through its verbs | `claude-licence-manager`, `model-usage`, `mesh-catalog`, `showcase` | each opens its own postgres; licences, usage and audit have no bucket | allowed by the principle, but invisible to a view | the same pattern as G2, per module: a declared bucket (ADR 0201) for the current state a person would look at | S per module |
| G7 | The bus does not watch itself | the `nats` module; the controller's advisories | no system account; no dead-letter stream; 2 slow consumers, 4 688 API errors, 4 407 connections in 39 h, unexplained; four consumers with nothing to consume | a failing or churning link is noticed only when someone asks | a system-account user for the `nats` module that turns the server's own events into conditions; the dead-letter stream design 25 promises; the reconnect rate and the delivery bucket's write rate investigated | S–M |
| G8 | History is short and in the store | every bucket keeps history 1; EVENTS keeps 7 days | §4.5 | a view of what happened beyond a week reads tables, not the bus | a history stream per kind of change that a view needs (deliveries, plans, builds, hand-acts), with its own retention, declared like a bucket | M |
| G9 | Answers are bounded by one message | the server's default `max_payload` | 1 MiB; paging since issue 314 | a view that asks verbs for lists pages; one that reads buckets does not | let views read buckets, not verbs; keep paging for verbs | S |
| G10 | Unused HTTP surfaces | the controller's `api` and `board` ports | `api` refuses every request; neither is named in a design | dead code that suggests a second way in | remove both once a view exists | S |
## 6. How the approval flow fits
Another effort is designing the approval flow: a person's decision, asked and answered on the bus,
generic over what is being approved. It continues to-be 46 §6 (asks) and §10 (asks that authorise)
under ADR 0234 and ADR 0258. This effort does not duplicate it. It notes only where it meets the
three principles:
- **An open ask is current state.** It belongs in a bucket, keyed by the ask, so a view lists what
waits for the operator and watches it change. The messenger's `open` bucket is the precedent.
- **An answer is an event.** The proof it carries is checked by the controller, never by the view
that collected it (ADR 0258: a click proves only that something at the session chose).
- **A view that lets the operator answer** needs G1's person-shaped credential. That credential lets
the controller tell the operator's own answer from anything else that holds the session.
## 7. What this effort did not read
- **The TypeScript of the tool runner** (`node-tools/src`). The Go part was read.
- **Each media app's own code beyond its provisioner and tools.** The apps themselves are upstream
software and are judged by §2.1.
- **Live traffic per subject**, beyond the subscription counters the server keeps.
@@ -0,0 +1,81 @@
# 036 — The target: a mesh that happens on the bus
This is where the gaps in [01-evidence.md](01-evidence.md) point. It is a picture to test proposals
against, not a design. Each paragraph names the gap it answers.
## Every link between modules is a subject
A module owns its state: its files, its database, its container. It is reached only by its tools, its
seat's verbs, its events and its buckets.
- **No module acts on another's container, socket or files.** A store module serves its own dump. The
registry says what it holds. The resolver's verbs are called over the bus like any other seat's. A
download client answers whether it is reachable with a key (G5).
- **Every act is grantable.** Because every act is a subject, the bus can refuse it, and the controller
can say who may perform it.
The bus is not for bulk bytes, and two kinds of traffic stay off it by name:
- **Bulk bytes stay where they are.** The bytes of an artifact and of a git repository, and an app
using its database, keep their own protocols.
- **Who reaches them is decided on the bus.** Where to reach them is a provision, and what exists is
announced on the bus (G4).
## Every important state is watchable
For each thing a person would look at, the owner keeps three things on the bus:
1. **A bucket** of current state, one key per thing: a node, an assignment, a plan, a build, a
delivery, a setting, a licence, an open ask.
2. **An event** for each change, naming the thing and what changed. Another module can consume it, and
a view can redraw from it.
3. **A history stream**, where the past matters, with a retention the owner declares (G8).
The store remains the owner's record where it needs one. The bucket is written from it on change, never
instead of it. The open conditions already work this way. Plans, builds, nodes, assignments, settings
and licences come next (G2, G6). A verb is still how something is asked or done. It stops being the
only way to see.
## Views are built from the bus
A view is a web page or a terminal UI that shows part of the mesh. *View* is proposed here as the
glossary word for this. It has no backend of its own:
- **It connects** to the bus over WebSocket, through the proxy, as the person using it.
- **It reads** the buckets it shows, watches them, and subscribes to the events that explain a change.
- **It acts** by calling the same tools and verbs an agent calls. A decision travels as an answer to
an ask, carrying its proof, and the controller checks it (the approval flow, §6 of the evidence).
The person is admitted by an **auth callout** served by a mesh module. That module checks the person's
login with the mesh's identity provider and issues a short-lived bus user. The user's grant is derived
from the view's declaration: these buckets to read, these events to hear, these tools to call. No
password is written into the server's file for a person, and a person's access ends when the credential
expires (G1).
## The bus survives a machine
Every machine runs a **leaf node**:
- **Local traffic stays local.** A module's connection is local, and tool calls between modules on one
machine keep working when the hub is away, as they must on a laptop behind a VPN client.
- **What must not be lost is replicated.** The control-node's server is one of three in a cluster, and
the declarations, the control stream and the controller's buckets are kept on three disks rather than
one. The nightly snapshot remains the guard against our own mistakes, not against a disk (G3).
## The bus watches itself
- **Server events become conditions.** The `nats` module holds a system-account user and turns the
server's own events into conditions, like any other probe: a slow consumer, a permission refused, a
client reconnecting in a loop, a consumer that nothing feeds.
- **Exhausted messages are kept.** A message delivered as often as allowed lands in a dead-letter stream
that a view can show (G7).
- **Nothing to rescue by paging.** Answers stay within one message because views read keys, not
whole lists (G9).
## What is not wanted
- **No view with a database of its own** that copies the mesh's state. That copy drifts, which is the
thing the mesh exists to prevent.
- **No HTTP API on the controller beside the bus** (G10).
- **No second signing hierarchy** for its own sake. The callout issues users under the account the mesh
already has, unless a separate account for people turns out to be the simpler grant.
@@ -0,0 +1,39 @@
---
status: active
initiated: 2026-10-08
touches:
- 02-DECISIONS/0262-a-preference-has-a-default-in-its-manifest-and-the-operators-own-value-has-none.md
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
- the catalogue's module manifests
- the media catalogue's module manifests
became: []
---
# 037 — The catalogue's hard-coded preferences
## What is investigated
[ADR 0262](../../02-DECISIONS/0262-a-preference-has-a-default-in-its-manifest-and-the-operators-own-value-has-none.md)
lets a manifest give a **preference** a default, which the mesh-wide settings and a node's settings
override. Before it, a value a person might want to tune per machine could only be written into a
module's file as a literal. This effort lists those literals in both catalogues: the value, where it
is, the key and default it would take as a preference, and why a person would change it.
## Why
The first case was the notifier's font size, which is less than half as tall on a laptop panel of
about 240 DPI as on a desktop monitor of about 110 DPI, while X is told 96 on both. The same is true of
every size, font and gap on the desktop, and of retention counts, timeouts and thresholds in the
services. A list says which modules to convert, in what order, and which values are shared between
modules and should keep one default.
## What it touches
The manifests of both catalogues, and through ADR 0262 the controller's `settings` verb, which lists
every declared preference. Nothing is changed here: each conversion is a change to its module, made on
the operator's yes.
## Documents
- [01 — the survey](01-the-survey.md): every candidate, by module, strongest first, with the modules
that already take settings and those that have nothing to convert.
@@ -0,0 +1,115 @@
# 01 — The survey
Both catalogues were read on 2026-10-08: every manifest's file contents and the sources they are
copied from, the shell code modules contribute to the login session, and the contributions to the
display session. Nothing was changed. Each row is a literal that is a preference in the sense of
[ADR 0262](../../02-DECISIONS/0262-a-preference-has-a-default-in-its-manifest-and-the-operators-own-value-has-none.md):
true of the software, and only tuned per machine or per mesh. The proposed default is always today's
value, so converting a module changes no machine until a value is set.
Values that are the operator's own (names, addresses, accounts, secrets) are left out, as are values the
mesh assigns (`${port:…}`, `${dir:…}`, `${machine:…}`, `${bound:…}`, `${secret:…}`) and structure nobody
tunes. Every proposed key passes ADR 0262's whole-word check.
**Where a literal sits decides what converting it needs.** A row marked *(shell)* is in code a module
contributes to the login shell, and one marked *(contribution)* is in a contribution to the display
session. ADR 0262 fills `${setting:…}` in files, contributions and served facts, but not in shell code,
so the *(shell)* rows need the controller to fill shell code too before they can convert. A row marked
*(compiled)* is in a module's own program and needs the program to read the value from a file first.
## The desktop, strongest first
| Module | Where | Today | Key | Why a person changes it |
|---|---|---|---|---|
| xorg | the X resources file | `Xft.dpi: 96` | `dpi` | A laptop panel of about 240 DPI and a desktop monitor of about 110 DPI want different values; the file's own comment already expects a setting |
| xterm | X resources *(shell)* | `faceSize: 12`, face JetBrainsMono Nerd Font, `saveLines: 10000` | `font-size`, `font`, `scrollback-lines` | Terminal text size per screen; the face and history are taste |
| i3 | its configuration | `font pango:JetBrainsMono Nerd Font 11` | `font-size` | Title text size follows the screen's density |
| i3 | its configuration | `gaps inner 0`, `gaps outer 0`, `default_border pixel 1`, resize step 10 px | `inner-gap`, `outer-gap`, `border-width`, `resize-step` | Taste and density; a 1-pixel border is hard to see on a dense panel |
| i3 | its configuration | focused colour `#de5200` | `accent-colour` | Taste; the same colour is in the bar and the launcher (see below) |
| i3status-rust | the display-session contribution *(contribution)* | bar font size 11 | `font-size` | The bar's height follows it |
| i3status-rust | the top bar | window title cut at 50 characters | `title-width` | How much title fits depends on the screen's width |
| i3status-rust | the bottom bar | disk warning at 850 GB, critical at 920 GB | `disk-warning`, `disk-critical` | Thresholds written for one disk; wrong on a smaller laptop drive |
| i3status-rust | the bottom bar | updates warn at 10, polled every 1800 s; clock format with seconds, every 1 s; container block every 2 s, disk every 20 s | `updates-threshold`, `updates-interval`, `clock-format`, `refresh-interval` | Polling costs battery; the clock is taste |
| rofi | its theme | font size 11, width 600 px, 8 lines, icon 24 px, padding 10 px, message font size 11 | `font-size`, `width`, `lines`, `icon-size`, `padding`, `message-font-size` | The launcher's size per screen |
| rofi | the power menu theme | width 800 px, font size 32 | `power-menu-width`, `power-menu-font-size` | Too large on a small panel. The power menu script also passes 350 px, which overrides 800: one of the two is dead |
| rofi | its configuration | history of 25 | `history-size` | History length |
| screen-lock | the session's X settings *(shell)* | lock after 1800 s; display standby 1800 s, off 3600 s | `lock-after`, `display-standby-after`, `display-off-after` | The clearest laptop and desktop difference |
| screen-lock | its wrapper script | blur 5, ring width 7.0, accent `#ca4a00`, time and date formats | `blur`, `ring-width`, `accent-colour`, `clock-format`, `date-format` | Look, density and locale |
| picom | its configuration | terminal opacity 0.95 focused and 0.75 not; tooltip 0.95; fade step 0.05; corner radius 0 | `terminal-opacity`, `inactive-terminal-opacity`, `tooltip-opacity`, `fade-step`, `corner-radius` | Taste |
| picom | its configuration | `backend = "glx"` with vsync | `backend` | Depends on each machine's graphics driver |
| adwaita | GTK 3 and 4 settings, Qt settings, and the GNOME settings *(shell)* | interface font Inter 11, monospace 11; cursor size 24 (also in X resources and GNOME settings); double-click 400 ms, 3 scroll lines | `font-size`, `cursor-size`, `double-click-interval`, `scroll-lines` | Interface size on a dense panel; the cursor size must change in all four places together |
| fonts | the fontconfig defaults | monospace JetBrainsMono Nerd Font, sans Inter, serif Noto Serif | `monospace-face`, `sans-face`, `serif-face` | Taste; every desktop module names these faces again |
| the laptop's hardware module | touchpad configuration and its script | acceleration 0.15, tapping on, natural scrolling on | `touchpad-speed`, `tap-to-click`, `natural-scrolling` | Pointer feel; the script repeats 0.15 |
| the laptop's hardware module | the power daemon's battery levels | low 15 %, critical 10 %, act at 7 %, action suspend | `battery-low`, `battery-critical`, `battery-action-at`, `battery-action` | Thresholds move as a battery wears |
| the laptop's hardware module | its scripts | brightness step 5; on-screen display for 1500 ms | `brightness-step`, `notification-timeout` | Feel |
| feh | the wallpaper command | `--bg-fill` | `wallpaper-mode` | Fill, scale or centre is taste |
| zsh | its rc file | `HISTSIZE` and `SAVEHIST` 100000 | `history-size` | History length |
| lemurs | its configuration | background black, border white | `background-colour` | Low value; only the start of the file was read |
## Services
| Module | Where | Today | Key | Why a person changes it |
|---|---|---|---|---|
| restic | its program *(compiled)* | keep 14 daily, 8 weekly, 6 monthly | `keep-daily`, `keep-weekly`, `keep-monthly` | Retention is the clearest service preference, and today it cannot be changed without a new build |
| logrotate | its configuration | weekly, keep 4; the login record monthly, keep 1 | `rotate-period`, `rotate-count`, `wtmp-rotate-count` | Retention per machine and disk |
| docker | the prune unit and timer | prune what is older than 168 h, weekly | `prune-age`, `prune-schedule` | How long images and build cache stay |
| docker | the daemon's configuration | no log rotation at all | `log-size`, `log-files` | **A new value, not a conversion:** container logs grow without bound today. A default such as 10 MB and 3 files changes every machine, so it is a decision of its own |
| memory-pressure | the zram generator | `min(ram / 2, 16384)` | `zram-size` | RAM and workload |
| memory-pressure | the out-of-memory daemon and the session's limit | swap used 90 %, pressure 60 % for 20 s; the session 80 % | `swap-used-limit`, `pressure-limit`, `pressure-duration`, `session-pressure-limit` | How early processes are killed |
| time-sync | its servers | a regional pool | `pool-region` | The region is the mesh's |
| pacman | its configuration and the mirror list's options | 5 parallel downloads; a country list, the latest 20, sorted by rate | `parallel-downloads`, `mirror-countries`, `mirror-count` | Bandwidth and region |
| fail2ban | its jails | ban 1 day after 3 failures in 1 day; repeat offenders 4 weeks after 2 in 2 weeks | `ban-time`, `find-time`, `max-retries`, `repeat-ban-time`, `repeat-find-time`, `repeat-max-retries` | Strictness |
| sshd | its configuration | port 22 | `listen-port` | A port people move. The filter and the jail name the port too, and must follow it |
| dnsmasq | its configuration | two public resolvers | `upstream-resolvers` | The choice of resolver |
| icecast | its configuration | client timeout 30 s, source 10 s, burst 65535, queue 524288, log level 3, log size 10000 | `client-timeout`, `source-timeout`, `burst-size`, `log-size` | Streaming tuning |
| mailu | its environment file | message size 50 MB, 200 messages a day, sign-in rate limits, fetch every 600 s, log level | `message-size-limit`, `message-rate-limit`, `sign-in-rate-limit`, `fetch-delay`, `log-level` | Real limits a person sets |
| matrix | the homeserver's configuration | request size 20 MB | `upload-size-limit` | The upload cap |
| supabase | three files | a sign-in lifetime of 3600 s, written three times | `session-lifetime` | One value in three places, which a setting keeps equal |
| supabase | the functions' main | 150 MB, 60 s | `function-memory-limit`, `function-time-limit` | Function limits |
| power | its stop unit and script | 120 s, 30 s | (weak) | Bounds on shutdown work, rarely changed |
## The media catalogue
| Module | Where | Today | Key | Why |
|---|---|---|---|---|
| qbittorrent | its web login script | ban for 60 s | `ban-duration` | How long a failed login is locked out |
| tautulli | its start script | wait 60 s for the media server | `media-server-wait` | Low value |
The other media modules carry only identities from settings, key-provisioning scripts and mergeable
files. Nothing to convert.
## Already taking settings
- **`${setting:…}`:** the notifier (font size and width, the first preferences), power, hostname, xdg,
keycloak, mailu, lab, and every media module.
- **A mergeable file:** baserow, claude-code, the licence manager, confluence, gitea, gitlab, grafana,
home-assistant, icecast, influxdb, jira, keycloak, letta, mailu, matrix, mesh-watcher, messenger,
nextcloud, nodered, records, route-adapter, searxng, step-ca, supabase, unifi; in the media
catalogue bazarr, bookshelf, jackett, kometa, lidarr, nzbget, ombi, qbittorrent, radarr, sonarr and
tautulli. A mergeable file's content already is its defaults; ADR 0164's declaration would list its
keys.
## Nothing to convert
avahi, blueman, bluetooth, build-agent, ca-trust, clipmenu (its 1-second timeout is a safety bound),
cups, dbus, de-spiegel, disk-load, distribution, dmenu, gnome-keyring, hello-web, invoicing, letta,
local-model-consumer, localization (the language and keymap are the operator's, not a preference),
mesh-catalog, minio, model-usage, mongodb, mosquitto, mssql, n8n, nats, networkmanager, nftables,
node-env, only-office, redis, route-proxy, ssh-client, sudo, systemd, systemd-networkd,
systemd-resolved, triggerhappy, umami, xclip. A container's internal port is structure, not a
preference.
## What follows from it
- **Shared values want one default each.** The accent colour is `#de5200` in the window manager, the
bar and the launcher, and `#ca4a00` in the lock screen. The interface font size is 11 in the window
manager, the bar, the launcher and the GTK, Qt and GNOME settings, and 12 in the terminal. As one
setting per module they would drift. Either each module declares the same default and a person sets
them together, or the mesh grows a preference several modules read. That is a decision, not a
conversion.
- **Three prerequisites in the controller and the modules:** filling `${setting:…}` in shell code, for
the *(shell)* rows; the restic program reading its retention from a file; and, for the container
runtime's log rotation, a decision on the new default.
- **Order:** the density rows first (xorg's DPI, then the font sizes of the window manager, the bar,
the launcher, the terminal and the interface, and the cursor size), because the laptop panel is the
case that started this. Then the lock and display timeouts, then retention and thresholds.
@@ -0,0 +1,82 @@
---
status: graduated
initiated: 2026-10-08
touches:
- 04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md
- 04-ISSUES/154-a-machines-own-network-is-not-a-reach/00-report.md
- 01-RESEARCH/027-the-system-layer-as-modules/02-candidates-and-questions.md
- 03-DESIGN/01-to-be/26-the-seats.md
- 03-DESIGN/01-to-be/48-a-module-says-how-it-is-healthy.md
- 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
- 02-DECISIONS/0051-shared-data-is-the-operators.md
- 02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md
- the catalogue's module manifests
- the controller's seat set
became:
- 03-DESIGN/01-to-be/52-shares-and-mounts.md
- 02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md
---
# 038 — Shares and mounts the mesh manages
## What is investigated
How the mesh shares a directory from one node and mounts it on the others, so that no machine
carries a hand-written export or a hand-written mount line. Three kinds of source are in scope:
1. **A node's share.** The home server shares its storage pool over NFS. A node that wants the files
mounts them.
2. **A device outside the mesh that is not always there.** A gaming device on the home network shares
a folder over SMB, and only while it is switched on. A USB disk is the same case.
3. **What the mesh should show and say** when a mount works, when it does not, and when its source is
simply away.
## Why
On 2026-10-08 the operator found two failed mounts on the workstation, and said two things:
- about the media library: *"This should be handled by the mesh. If a node exposes an NFS share, it
should get configured on all the nodes in their fstab?"*
- about the gaming device's share: *"This mount only works if I plug in my recalbox machine."*
They also set a requirement: **the home server shares its storage pool over NFS**, the workstation's
library mount comes from that share, the export reaches only the mesh's private network, it squashes
root, and the media modules' data (the Plex library is sacred) must not be put at risk.
[Issue 169](../../04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md)
predicted the first failure on 2026-09-30: nothing declares the share's ports, so the packet filter
closes them. This effort measures what happened, and proposes the decision issue 169 asked for.
## What was found
The detail is in [01-evidence.md](01-evidence.md). In short:
1. **The library mount times out because the home server's packet filter drops NFS.** The server
exports the library and its NFS service runs. The mesh's filter accepts only what a module
`listens` on, and no module listens on 2049, 111 or 445. The server's NFS counters read zero I/O
since its last boot six days earlier, so no client has used either share since then.
2. **The gaming device's mount fails because the gaming device is off.** It is an SMB share on the device itself,
not a disk. The fstab line has no `nofail` and no automount, so each boot records a failed unit.
The line also carries the share's password in a world-readable file.
3. **The export is not where a person would look.** The server's `/etc/exports` holds only comments.
The export comes from a property of the ZFS dataset, which writes its own file in `/etc/exports.d`.
The library is a child dataset of the pool, so an export of the pool's root does not show the
library's files unless it crosses into child filesystems.
4. **User ids do not match across nodes.** The library is owned by a service account and group. The
operator's account has the same id (1000) on every node, but on the home server that id belongs to
a different account, which owns the pool's backup repository. NFS with `sec=sys` trusts the client's
id. A plain export of the whole pool would give the workstation's operator account the backup
repository.
5. **No module, and until now no tool, covers any of this.** No module in either catalogue exports or
mounts anything. No mesh tool read a machine's fstab, its mount units or its exports, so this effort
read them through the login shell. A read-only `mounts` module now proposes those tools.
## Where it goes
[02-options.md](02-options.md) weighs the options. The decision is
[ADR 0263](../../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md),
and the design is [52 — Shares and mounts](../../03-DESIGN/01-to-be/52-shares-and-mounts.md).
On 2026-10-08 the operator chose **one share per folder**, so the backups are never exported, and
**mesh machines only**, over the private network. Which nodes mount a share, and whether the laptop
mounts read-only, is decided at assignment.
@@ -0,0 +1,134 @@
# 038 — Evidence
Measured on 2026-10-08 on the four nodes, read only, through the mesh's tools: `node-service-manager`
(`failed`, `status`, `journal`), `node-uplink.links`, `node-packet-filter.rules`,
`disk-load.disk_filesystems`, `docker.docker_list`, and `node-login-shell.execute` for the files no
tool read (§7). Addresses are given in documentation ranges: the home network (the underlay) as
`192.0.2.0/24`. Node roles: the **home server**, the **workstation**, the **laptop**, the **control-node**.
## 1. The workstation's two failed mounts
`node-service-manager.failed` on the workstation lists two failed system units, both generated from
`/etc/fstab` (their unit file is under `/run/systemd/generator`, and `mesh_declared` is false):
| unit | the fstab line (password removed) | what the journal says |
|---|---|---|
| the library | `<home server>:<library> <same path> nfs defaults,_netdev 0 0` | `Mounting timed out. Terminating.` 90 s after boot, at every boot since 2026-10-04; the last successful mount was at the boot of 2026-08-23 |
| the gaming device | `//<device>/Share <mountpoint> cifs username=root,password=…,iocharset=utf8,sec=ntlmssp 0 0` | `mount error(113): could not connect to <device>` at every boot |
Neither line has `nofail` or `x-systemd.automount`. Both name the source by its underlay address.
`remote-fs.target` is inactive on the workstation, because both of its members failed.
## 2. Why the library times out
- The home server exports the library, and its NFS and Samba services run. `nfs-server` and `smb`
are active and enabled, and ports 2049, 111 and 445 are listening on every address.
- **The mesh's packet filter drops them.** The mesh's table on the home server has `policy drop` on
its input chain for the underlay link and the private network link. It accepts a port only where a
module `listens` on it ([ADR 0045](../../02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)).
No rule names 2049, 111 or 445.
- From the workstation, a TCP connection to the home server's port 2049 fails, and one to port 22 on
the same address succeeds.
- The home server's NFS counters (`/proc/net/rpc/nfsd`) read `io 0 0` since its boot on 2026-10-04.
No established connection to 2049 or 445 exists. **Nobody has used either share since that boot.**
This is the failure [issue 169](../../04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md)
predicted: the shares close silently once the filter is the sum of what modules listen on.
## 3. What the home server exports, and from where
- `/etc/exports` holds only the distribution's comments; it was last written on 2026-09-20.
- The export comes from ZFS. The library dataset has `sharenfs=rw=@192.0.2.0/24` and `sharesmb=on`.
ZFS writes `/etc/exports.d/zfs.exports` itself, marked *do not edit*. The kernel's export table
(`/var/lib/nfs/etab`) holds one entry: the library, to the underlay range, `rw,sync,root_squash,
no_all_squash,secure,no_subtree_check,sec=sys`.
- The Samba configuration has one share, `media`, on the library, `valid users = media`,
`force user = media`, writable.
- **The pool is two filesystems.** The pool is mounted at its root, and the library is a child dataset
mounted under it. An NFS export of the pool's root does not show the library's files unless the
export crosses into child filesystems (`crossmnt`); without it, a client sees an empty directory
where the library should be.
## 4. What lives on the pool, and who uses it
The pool's root holds five directories:
| directory | owner | what it is |
|---|---|---|
| the library (child dataset, about 90 TiB used) | the media account and group, `0775` | movies, series, anime, music, audiobooks and more |
| downloads | the media account and group | the download clients' and the library managers' working area |
| archive | root | the operator's archive |
| a second Plex folder | the operator's account on the home server | an older library folder |
| the backup repository | the operator's account on the home server, `0700` | the restic repository the backup seat writes |
Plex's own configuration and database live on a separate disk, not on the pool.
Eight running containers bind paths on the pool, all of them as plain bind mounts of local paths:
Plex (the library's folders, all read-only), the library managers for series, movies, music and
subtitles, two download clients, and one automation app. **Exporting the pool over NFS changes none of
these paths and none of their owners.** An export adds a way in; it does not move or chown anything.
The only way an export endangers this data is by what it lets a client do (§5).
## 5. User and group ids across the nodes
| node | id 1000 | the media account | the media group |
|---|---|---|---|
| home server | a separate account, which owns the backup repository and the older Plex folder | exists, owns the library | exists |
| workstation | the operator | exists, same ids as on the server | exists; the operator is a member |
| laptop | the operator | absent | absent |
| control-node | the operator | absent | absent |
The library managers on the home server run as the media account and group, set by their `puid` and
`pgid` settings.
With `sec=sys`, the server believes the id a client sends; `root_squash` changes only root. So:
- **A client's operator account (1000) is the home server's other account (1000).** An export of the
pool's root would let it read and write the backup repository and the older Plex folder.
- **A file written by the laptop's operator lands owned by 1000**, not by the media account, and the
library managers may then fail to rename or delete it.
- A device on the underlay that is not a node can claim any id. That is a reason to export to the
private network only, where every address is a node.
## 6. The gaming device's share
- The gaming device is a separate machine on the underlay, a retro-gaming device that shares a folder named
`Share` over SMB. It is not a USB disk. The router's DNS knows its name. It was switched off during
the survey and did not answer a ping.
- The workstation has `cifs-utils`, `avahi` and `nss-mdns`, and its `hosts:` line contains
`mdns4_minimal`. **The packet filter drops inbound mDNS** on every node, as the `avahi` module's
README already records. So discovery by mDNS cannot see the gaming device arrive today. Waiting for it
would first need a local-link reach for `listens`, which is the question of
[issue 154](../../04-ISSUES/154-a-machines-own-network-is-not-a-reach/00-report.md).
- The share's password is written in `/etc/fstab`, which is mode `0644`. Every account on the
workstation can read it.
## 7. What the catalogue and the tools cover
- **No module exports or mounts.** No manifest among the 117 modules of the core catalogue and the 12
of the media catalogue writes an export, a Samba share, a mount unit or an fstab line.
- **`disk-load.disk_filesystems`** lists NFS and CIFS filesystems that are mounted now, with their space.
It cannot see a mount that failed, an fstab line, a mount unit or an export.
- **`node-service-manager`** shows a mount unit's state and journal, but not what it mounts or with
which options.
- **No tool read a machine's fstab, its mount and automount units with their sources, or its NFS and
SMB exports.** This effort read them with `node-login-shell.execute`, which runs a shell command as
the operator account. That is a work-around for a missing tool, so a read-only `mounts` module with
two tools, `mounts_list` and `mounts_exports`, was opened in the catalogue alongside this effort. It
redacts passwords in mount options and never touches a network mount point, so a hung server cannot
hang the tool.
## 8. Prior work
- **Issue 169** (open, the operator's proposal of 2026-09-30): two seats, `nfs-share` and `smb-share`,
because the two protocols share an intent and not a contract; a consumer module that writes a
systemd `.mount` unit from its binding; the modules that use the files never learn about NFS; the
data-loss case of a writer starting before its mount; and identity crossing the wire.
- **Research 027**, option M2: each client module writes its own systemd mount and automount unit, and
`/etc/fstab` stays the machine's.
- **Research 026**, the tools list: `exports`, `shares`, `clients`, `reload` on the server;
`mounts`, `mount`, `unmount`, `test` on the client.
- **ADR 0092**: a credential for something outside the mesh is an operator-delivered secret, accepted
into the vault (`secret accept`) and never replaced by a made one.
- **`${machine:mesh-range}`** already exists as a placeholder in two manifests, so an export line can
name the private network's range without naming a node.
@@ -0,0 +1,146 @@
# 038 — Options
Each question below lists the options weighed and the one recommended. The recommendations together
became [ADR 0263](../../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md).
## Q1. Who writes the export
| option | for | against |
|---|---|---|
| A. Keep the ZFS `sharenfs` property, and have a module declare only the ports | nothing new on disk | the export lives in a dataset property, beside the operator's data; a module would be setting properties on data the mesh does not own (ADR 0051); the options are one string with no room for squashing |
| B. A module owns `/etc/exports` whole | one file, one owner | the file is the machine's; a second exporter on the same node would collide |
| **C. A module, `nfs-server`, writes its own file in `/etc/exports.d`, and the ZFS property is turned off when the module's export is live** | the mesh owns exactly its file; the distribution reads the directory; unassigning removes the file | a migration step: the property must be cleared by an act, not by hand |
**Recommended: C.** The module holds a node seat, `nfs-share`, so one module exports on a node, and it
provides the provision `nfs-share` (issue 169's proposal). Samba is left as found, with its own seat
`smb-share` later; nothing on the mesh uses it, and the gaming device's SMB share is a client case (Q6).
## Q2. What is exported, and to whom
The operator asked for the pool's root. Exporting it plainly has two problems (evidence §3 and §5): the
library is a child filesystem, so the export needs `crossmnt` to show it; and with `sec=sys`, any client
account with id 1000 becomes the server's own 1000, which owns the backup repository.
| option | for | against |
|---|---|---|
| A. The pool's root, `rw,root_squash`, to the underlay range | what the operator literally asked; what exists today (but for the library only) | gives the backup repository to every client's operator account; open to every device on the underlay, which can claim any id |
| B. The pool's root, `rw,crossmnt,all_squash`, every client mapped to the media account, to the private network only | one share, as asked; every file written lands owned by the media account; the `0700` backup repository is unreadable to the media account, so it stays closed | every client writes as one account, so there is no per-person permission on the share; top-level folders other than the library are writable by every client |
| **C. One share per subtree: the library and the downloads, each `rw,all_squash` to the media account, to the private network only; the backup repository and the archive not exported** | the backup repository is not reachable at all; ownership is right; a consumer mounts only what it needs | two shares instead of one; a later folder needs a new share entry (one setting) |
| D. Kerberos (`sec=krb5`) with id mapping | real per-person identity | needs a KDC and keytabs on every node; far beyond the problem |
**Recommended: C, with B as the operator's alternative if one share is wanted.** Both use
`all_squash` with `anonuid` and `anongid` set to the owner of the exported path, read on the server
(`${access:<id>:uid}`, from issue 153's fix), never written as numbers in a manifest. Both export to
`${machine:mesh-range}` only, so every client is a node, reached over the private network. `root_squash`
is implied by `all_squash`. `sync`, `secure`, `no_subtree_check` stay as they are today. The server
serves NFS version 4 only, so only port 2049 is opened, `from: mesh`; 111 and the mountd ports stay
closed.
**What this does not touch:** the local bind mounts of Plex and the library managers (evidence §4),
the datasets' owners and modes, and the Samba share.
## Q3. How a node mounts a share
| option | for | against |
|---|---|---|
| A. The mesh writes lines into `/etc/fstab` | the file people know | one shared file and one ordered list; the node-engine can place a marked block but every line competes with the operator's |
| B. `autofs` with its own maps | mature automounter | a second daemon doing what systemd already does |
| **C. A `.mount` unit and an `.automount` unit per share, written by one module, `mounts`, which holds the node seat `node-mounts`** | a unit is a unit: its state, its journal, its health; an automount never holds the boot; unassigning removes it; no shared file | units have to be named by the escaped mount path, which the module computes |
**Recommended: C.** The `.automount` is enabled under `remote-fs.target`; the `.mount` is not enabled
on its own, so nothing mounts at boot and nothing fails at boot. The mount is made at first access and
released after an idle time. The options carried:
- `x-systemd.automount` semantics through the `.automount` unit, `TimeoutIdleSec` (default 10 minutes);
- a mount timeout, `TimeoutSec` on the `.mount` (default 30 s for a node's share, 10 s for an
occasional one), instead of systemd's 90 s;
- `_netdev` and an order after the private network's link is up;
- for NFS, `vers=4.2,hard,timeo=150,retrans=3` on a read-write share (no silent write loss) and
`soft` allowed for a read-only one, as a setting.
An automount also closes issue 169's data-loss case: the mount point is always an autofs mount, so a
process that writes there either triggers the mount or gets an error. It never writes into the empty
directory underneath.
## Q4. Which nodes get a share
The operator suggested every node. Weighed:
| option | for | against |
|---|---|---|
| A. Every node mounts every share | no assignment to make | the control-node is the stably reachable machine the controller runs on, and gains a writable path into the library for no use; the laptop, behind a corporate VPN client, would try a mount whenever a file manager looks at the path |
| B. A node mounts a share only when a module on it requires it | precise | a person who wants the files in their file manager has no module that requires them |
| **C. A node mounts what its `mounts` assignment lists; the list may be set once at the mesh-wide layer** | opt-in per node, and "every node" is one setting at the mesh-wide layer, with a node's own layer to leave a share out | two layers to read when asking why a node has a share; `mounts_list` says which layer gave it |
**Recommended: C, assigned to the workstation and the laptop, not to the control-node.** A module that needs
the files (a player, an indexer) requires `nfs-share` as well; the controller then adds the share to
that node's `mounts` unit set, so B is the special case of C, not a second way.
## Q5. Health
| case | finding | condition |
|---|---|---|
| a node's share, automount armed, server answers | healthy | none |
| a node's share, server's 2049 unreachable from this node for 5 minutes | not healthy | `share-unreachable`, a warning: *"The home server's share `library` is not reachable from the workstation since 19:21."* |
| a node's share, the mount failed with an error other than a timeout | not healthy | the same condition, with the error |
| an occasional source, absent | **absent**, a state, not a finding | none |
| an occasional source, present but the mount fails | not healthy | `share-unreachable` |
| the server: the export table lacks a declared export, or `nfs-server` is down | not healthy | the module's ordinary health condition |
The `mounts` module's code probes each source's port on a timer and keeps each share's state in its
own key-value bucket (ADR 0201): `mounted`, `armed`, `absent`, `unreachable`, with since when. Its
health reads that bucket. Nobody reads its files.
## Q6. Occasional sources
The gaming device is not a node, so it cannot hold a module or provide a provision. Weighed:
| option | for | against |
|---|---|---|
| A. A fstab line with `nofail,x-systemd.automount` | small | a hand-written line again, and the password stays in the file |
| **B. An external source in the `mounts` assignment's settings: protocol, name, share, mount point, presence `occasional`, and the name of a credential** | the same unit pair and tools as a node's share; the password goes to the vault | a share with no provider, so no binding: the module checks the name itself |
| C. Wait for the device by mDNS and mount when it appears | mounts before anyone asks | the packet filter drops mDNS today (evidence §6); depends on issue 154 |
**Recommended: B now, C when issue 154 gives `listens` a local-link reach.** With an automount, nothing
has to happen when the gaming device appears: the next access mounts it. The probe only keeps the state
honest, so `mounts_list` says *"the share `retro-games` is not present since 18:02"*. A USB disk is the same kind of source,
with presence taken from its device unit (`BindsTo=` on the disk's by-uuid device) instead of a probe.
**The credential:** the share's password is an operator-delivered secret, accepted into the vault with
`secret accept` (ADR 0092), and reaches the node as a file the node-engine unseals (ADR 0086). The unit
names it as `credentials=<that file>`. Nothing in a repository or in `/etc/fstab` holds it.
## Q7. Taking over the existing fstab lines
The two lines are the operator's. The node-engine's own `.mount` unit in `/etc/systemd/system` already
takes precedence over the generated unit of the same name, but the line would still be read by every
other tool and person.
**Recommended:** the `mounts` module gets an act, `adopt`, that for one mount point:
1. checks a unit pair the module placed is armed for that mount point, and refuses if not;
2. comments out the fstab line, with a marker naming the module and the date, and keeps a copy of the
file beside it;
3. reloads the service manager and says what it did.
It runs only on a person's word. The library line is adopted after the home server's share works over
the private network; the gaming device's after its credential is accepted.
## Q8. Over the bus
Every exchange between the modules is over NATS (research 036):
- the controller wires provider and consumer through the provision; the binding carries `at` (the
server's private-network name), `path` and `options`;
- `nfs-server` keeps its exports and the clients it sees in its bucket, and answers `test` (does it
export this path to this node) by request and reply;
- `mounts` keeps each share's state in its bucket; a view or the controller watches it;
- no module reads another's files (ADR 0260).
## The operator's answers (2026-10-08)
1. **The pool's root, or one share per folder?** One share per folder (Q2 option C), so the backup
repository is never exported.
2. **Underlay clients?** Mesh machines only, over the private network.
3. **Every node, or the nodes that ask? Read-write from the laptop?** Decided at assignment, which is the
operator's; the recommendation (Q4 option C; the laptop read-only) stands in the design.
@@ -0,0 +1,109 @@
---
status: active
initiated: 2026-10-08
touches: []
became: []
---
# 039 — A tool or verb that needs a person's approval
A note to design from later, not a design.
## What is investigated
Whether a module's tool or a seat's verb can declare, in its manifest, that **calling it needs a
person's approval**, and the mesh then refuses the call without one.
The operator's idea, 2026-10-08: *"I think we need to be able to mark tools as 'requires human
approval'."*
The shape, as first seen:
- **The declaration is the tool's own.** The manifest marks a tool or verb as needing approval, beside
the rest of what it declares about itself.
- **The mesh enforces it, not the MCP client.** The tool runner, or the controller for a seat verb,
refuses the call unless it carries a **warrant**: the router's record that a person chose one option
of one ask, from the flow ADR 0259 sets out (hq pull request 211, not yet merged). An agent may ask;
it cannot act alone. A client-side prompt is no boundary, because any tool runner may call any tool
on any node (ADR 0175).
- **The ask carries** what is to be done, why, the exact arguments, and who asks. Its options are
Approve and Decline, Approve at the `approve` **assurance level**. On a warrant for Approve the call
goes ahead **once, with those arguments, before the warrant's expiry**; anything else is refused.
- **Where it is answered** until a channel that declares a verified sender (Telegram) is live: at the
operator's desk, or at the controller's terminal. Open: the desk answers only `acknowledge` options
([ADR 0258](../../02-DECISIONS/0258-a-desk-click-performs-only-an-acknowledgement-and-an-answer-is-a-one-time-ticket.md)),
so the desk alone cannot approve.
## Why now
On 2026-10-08 the only thing between an agent and acts like a snapshot of the media library, a
whole-mesh push or the bus's upgrade was the agent asking in chat before it called the tool. Nothing in
the mesh would have refused the call. The same day, several holes were found in exactly this layer:
an agent on the control-node could become root without a person, and through the login shell's tool an
agent on any node could run a command there as an account that can (ADR 0259 §8 as revised, and
ADR 0266, hq pull request 225, not yet merged). Once agents run without root, **the tools are the
boundary**, and a tool that can do lasting harm needs a person behind each call.
## The first case: the login shell's `execute`
The operator decided on 2026-10-08 that the login shell seat's verb `execute` is the first tool to
need approval, on every node, once the mark exists. It runs any command it is given as the node's
operator account, and that account has passwordless sudo, so one call is root on that node. Until the
mark exists, the verb is removed on the control-node.
The calls it carried (about 300 across the four nodes, counted on 2026-10-08): about a third ran
something with sudo; most of the rest were reads, such as reading or searching a file, listing
containers, asking a unit's state, listing sockets, or looking up a user or a name.
So **narrow read tools must replace the common reads first**, each a tool of the module that owns the
area. Otherwise nearly every approval asked is for a read, and the operator learns to approve without
reading, which makes the approval worth nothing.
## Candidates seen on 2026-10-08
| Tool or verb | Why a person |
|---|---|
| the login shell's `execute` | decided: see above |
| the new zfs module's `zfs_snapshot` | a snapshot of the media library holds its space; on a scratch dataset it does not matter |
| `mesh-controller.push` | sends builds to nodes outside a walk |
| the bus's upgrade, `mesh-controller.bus upgrade` | already a step a person starts ([ADR 0236](../../02-DECISIONS/0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md) rule 6); today "a person" is whoever calls it |
| `mesh-delivery.release` | the glossary defines it as a person's word, and nothing checks that a person said it |
| the controller's `plans` go and stop | starts or halts a walk |
| a secret's `rotate` | can cut off every consumer of a credential |
| the places and accesses settings | being made terminal-only; approval is the other way |
| the media library's share and mounts (`nfs-server`, `mounts`) assignment | the library is the operator's sacred data |
## What it bears on
- **ADR 0259** (pull request 211): asks, the router, warrants and assurance levels. This effort adds a
new kind of asker: the tool runner or the controller asking on behalf of a call. Who the asker is
matters, because a warrant is issued to one asker alone.
- **ADR 0266** (pull request 225): agents without root. It makes the tool layer the boundary this
effort guards.
- **The `record` upgrade policy** (ADR 0236): "a person's push" is today only a convention.
- **The held delivery's release**: the same.
- **Issue 336** (pull request 228, a hold nobody is told about): an approval waiting with nobody told is
the same fault, so a waiting approval must be said, as a condition or on a channel.
- **The hand-act log** (ADR 0234 §8): a call made on a warrant is recorded with the warrant.
## Open questions
1. **Per tool or per argument.** A snapshot of the library needs a person; one of a scratch dataset
does not. Is the mark on the tool, on an argument's value, or decided by the tool from its arguments?
2. **Who may approve.** Only the operator role exists today. Is that enough for every tool?
3. **Replay and expiry.** One warrant, one call: how is "these exact arguments" bound to the warrant,
and how long does a warrant stay good?
4. **A call waiting for approval.** How is it shown: a condition, the asks list, the call's own answer?
5. **The MCP client while it waits.** Does the call block until answered, or return "asked, waiting"
with a handle the agent calls again?
6. **The answer before a verified channel exists.** The desk is `acknowledge` only; is the controller's
terminal the place, and how does it prove a person there?
7. **Per call, or per session or time window.** For a tool called as often as `execute`, does each
call need its own approval, or may one approval cover one agent session, or a stated span of time?
A wider approval is less noise and more reach for whoever holds it.
8. **Audit.** Is a warranted call a hand-act, and where is a declined or expired one recorded?
## How it will be checked
Measured so far: only the login shell's calls, above. The next evidence to gather: how many tools and verbs in the catalogue act on
data or on other nodes, and of those, how many are guarded by anything but the caller's restraint.
@@ -0,0 +1,74 @@
---
status: graduated
initiated: 2026-10-09
touches:
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
- 03-DESIGN/01-to-be/40-building-the-operators-agent-and-its-licence-manager.md
- 03-DESIGN/00-as-is/15-the-agent-and-its-licences.md
- 02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0243-the-agent-module-removes-a-home-item-it-did-not-place-only-on-the-persons-word-and-keeps-a-copy.md
- 02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md
- 02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md
- 04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md
became:
- 02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
---
# 040 — The agent's home is the module's, and the operator's rules and lessons are registered through it
## What
Three things about the coding agent's own directory under a person's home (`~/.claude`), investigated
together because the operator raised them as one on 2026-10-09:
1. **Who owns the configuration files in that directory.** [ADR 0216](../../02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)
gave the agent module a home scope that "owns only what it placed"; everything else there is held as
found ([ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)).
The operator's direction reverses that for the configuration: *"the home-scope does own ~/.claude, so
it should be completely empty and manual edits should be forbidden."*
2. **Whose the rules in that directory are.** The rule files a person writes there today are the
person's own, on one machine. The operator's direction: *"Rules in my home directory are my 'personal
rules' which should also end up under control of the claude-code module and all other changes to
~/.claude should go through the claude-code module's mcp tools."*
3. **How an agent learns from a correction.** The operator's direction: *"register the decision, but make
sure it's clear and that you can/should be pro-active about it. For example, if I'm frustrated, try to
see why and try to prevent it in the future by warning yourself about stuff not to do to frustrate me
— kind of like a self-learning process."*
## Why
The home is the one place the agent's configuration still drifts. [01](01-what-the-home-holds-and-the-options.md)
measured it on 2026-10-09 across the four nodes: a skill to-be 36 retired in its first version is still on
all four; a person's instruction file is on three; the operator's two hand-written rules are on one; and
nothing in the mesh places, watches or raises anything about any of it. The corrections the operator has
given the agent live in the agent's own memory for one repository on one machine, 45 notes of which at least
9 are a correction of a way of working, shown to nobody and reaching no other node.
[Issue 340](../../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md)
closed the tools' route into the operator's home for the right reason: a tool cannot tell the operator from
an agent, so an agent could plant something every session obeys. Whatever owns the home must keep that
protection. The two routes the mesh now has for the operator's own word, the controller's terminal
([ADR 0272](../../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md))
and a warrant on an ask ([ADR 0259](../../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md)),
make it possible to write into the operator's home without trusting a tool's caller.
The record was searched first: `records_search` for "personal rules lessons frustration self-learning" and
for "hand edit home .claude drift condition" found nothing on 2026-10-09.
## What it touches
- **The agent module's design** ([to-be 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)
§1 and §8): what under the home is the module's, and the scopes.
- **ADR 0216 §5 and §6** (the home scope owns only what it placed; what it did not place is reported) and
**ADR 0243** (removal on the person's word): the mechanism changes, the decisions stand.
- **ADR 0182**: the class table holds; what changes is which class the configuration files are in.
- **ADR 0245**: the guard gains a refusal, and the session gains a reminder.
- **ADR 0259**: the module becomes an asker, for the operator's yes.
- **Issue 340**: the protection stands; the terminal and the warrant are the two ways in.
## Documents
- [01 — What the home holds, and the options](01-what-the-home-holds-and-the-options.md): the measurement
on four nodes, the agent's own memory as it stands, and the options weighed.
@@ -0,0 +1,130 @@
# 040 — What the home holds, and the options
## 1. The agent's directory on the laptop, 2026-10-09
Read by name on the laptop's own disk, contents not read. The directory holds 33 entries.
**Configuration, written by a person or by the program on a person's choice:**
| path | what | how it got there |
|---|---|---|
| `rules/` | 2 rule files (1779 and 755 bytes): how text meant to be pasted elsewhere is handed over; how git is used in a second forge's repositories | written by hand |
| `skills/` | 5 skills of the operator's own, for the operator's other work; and `skills/synced/`, 2 folders the vendor's own sync placed | by hand; by the program |
| `commands/` | empty | — |
| `settings.json` | 6 keys: the attribution, auto mode, feedback drafts, the model, the spinner tips, the model switch on a flag | by hand, and by the program on a `/model` or `/config` choice |
| `settings.local.json` | 1 key, `permissions`: the rules the program writes when a person answers "always allow" in a session | by the program, on a person's answer |
| `plugins/` | the program's record of its marketplaces (the vendor's and the mesh's `nox-mesh`) and installed plugins, and its cache | by the program |
| no `CLAUDE.md`, no `agents/`, no `hooks/` | the instruction file was removed on 2026-10-07 through the module's removal tool, with a copy kept (ADR 0243) | — |
**Working state, written by the program for itself** (24 entries): the credentials file (the module's own
code writes it, ADR 0183), `history.jsonl` (5 MB), `projects/` (30 directories, 1.5 GB, holding every
project's sessions and the agent's own memory), `sessions/`, `session-env/`, `shell-snapshots/`,
`file-history/`, `debug/`, `cache/`, `paste-cache/`, `telemetry/`, `stats-cache.json`, the daemon's lock,
log and status files, `state/`, `ide/`, `jobs/`, `feedback/`, `backups/`, `worktrees/`, and a few markers.
**The managed directory under `/etc`**, by contrast, holds exactly what the module renders: the instruction
file, the managed settings, the managed tool-server file, and the marketplace with the `nox-mesh` plugin.
## 2. The homes on four nodes, 2026-10-09
Read through each node's `claude_code_config_status` (the module's own status tool), the field listing the
home's own items beside what the mesh placed:
| node | home items the mesh did not place | placed by the mesh at home |
|---|---|---|
| the laptop | 2 rule files, 5 skills | none |
| the workstation | an instruction file, a `cleanup` skill, a subagent (a module reviewer) | none |
| the home server | an instruction file, a `cleanup` skill | none |
| the control-node | an instruction file, a `cleanup` skill | none |
Three things follow:
- **The retired skill is still everywhere.** To-be 36 §1 said on 2026-10-02 that the `cleanup` skill
becomes "nothing; it named the predecessor's forge". It is on all four nodes on 2026-10-09. Nothing
owns it, so nothing removed it.
- **The operator's rules exist on one node.** A rule about how the operator wants text handed over is as
true on the workstation as on the laptop. It is on the laptop alone, because the only way to put it
anywhere is to write the file there.
- **Nothing is raised about any of it.** The status tool lists these items when asked. No condition says
the laptop's rules are missing elsewhere, or that a skill the design retired is still loaded in every
session on four machines. The three mesh-wide instruction sections and the `nox-mesh` plugin (holding
the guard alone) are the only things the mesh renders for a session, and both are in the managed
directory, not the home.
## 3. The agent's own memory, 2026-10-09
The agent keeps notes for itself in the program's memory directory under `projects/` for one repository
on the laptop: 45 notes on 2026-10-09. At least 9 of them are a correction of a way of working that the
operator gave in a session, for example: finish a module before pushing it; use the mesh's tools, not
ssh; read the merge gate's verdict before merging; what is done is what the operator sees working;
no work-arounds when a tool is missing; a language choice the operator insists on. Each was learned once,
after the operator said it, sometimes with irritation.
Those notes:
- are on one machine, for one repository: a session on the workstation, or in another repository on the
laptop, starts without them;
- are written by the agent for itself, in its own words, and shown to nobody: the operator cannot see
what the agent taught itself, or correct it;
- are not read before a tool runs: a note saying "read the merge gate first" is read at session start,
if the agent opens it, and nothing says it again at the moment a merge is about to be called.
The mesh keeps one related record: the guard's record of every refusal and override
([ADR 0245](../../02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md) §5),
which is how a habit that found a way round is seen. It records what the guard refused, not what the
operator corrected.
## 4. What the vendor loads from the home
As documented by the vendor and confirmed on the laptop on 2026-10-09 (a session's own context names the
two rule files as "the user's private global instructions for all projects"): the home's `CLAUDE.md` and
every file under `rules/` are read into every session the account starts, in every project; `skills/`,
`commands/` and `agents/` are offered under their own names; `settings.json` is the account's settings,
below the managed settings; hooks come from `settings.json` and from a plugin. None of it is read for
another account, and none of it reaches another machine.
## 5. The options
### Who owns the configuration under the home
1. **As today: the module owns what it placed, the rest is found** (ADR 0216 §5, ADR 0182). Rejected by
the operator's direction, and by §2: a rule on one node reaches no other, a retired skill stays on
four, and nothing is raised. There is a second reason: the guard refuses an agent that reaches a
machine by ssh, and refuses nothing that writes `~/.claude/rules/x.md`. On a node where the agent
runs as the operator, that is a rule planted in every later session, by hand, with no record.
2. **The module owns the whole directory and regenerates it.** Rejected in ADR 0182 (option 1), and still:
the working state is the program's, and a render that touched `projects/` would erase a season of
memory.
3. **The module owns every configuration file by name, excludes the program's working state by name,
renders the configuration from registrations, and reports a hand edit as a condition.** Chosen.
### Whose the rules are
1. **Per node** (the home scope as ADR 0216 defines it: one account's directory on one machine). Rejected
as the only scope: the operator is one person on four nodes, and §2 shows what one-node rules give.
2. **Mesh-wide instruction sections**, which already reach every node. Rejected as the place for them:
those are the mesh's word to every session, written at the controller's terminal, about the mesh.
A personal rule is the operator's word about the operator: how to hand over text, which tools their
other employer's repositories need. It belongs to the person and follows the person's agent, into the
home, under the person's own names.
3. **A new kind, the personal rule, registered for the operator and rendered into the agent's home on
every node.** Chosen. The same scope serves the operator's own skills, commands, subagents and settings.
### How a lesson becomes a rule
1. **The agent writes the rule itself.** Rejected: an agent that writes its own rules can loosen them, the
operator never sees what it taught itself, and issue 340's reasoning holds — text that every session
reads is written on the operator's word, never on an agent's.
2. **Lessons stay in the agent's memory.** Rejected by §3.
3. **A lessons register in the module. An agent proposes a lesson; the operator says yes or no; a yes
makes it a personal rule, rendered everywhere; the agent is reminded of the rules that bear on what it
is about to do.** Chosen.
### How the operator's yes reaches the operator's home
1. **Trust the tool's caller.** Rejected in issue 340.
2. **The controller's terminal only.** Safe; kept as one of two ways. It makes a yes in a session a trip
to the control-node, which is what the operator asked to avoid.
3. **A warrant on an authorising ask** (ADR 0259): the module asks the operator, with the exact text
bound in the ask, and acts on the warrant alone. Chosen beside 2. The approve option needs a channel
that proves its sender; until one answers, the terminal is the way.
@@ -0,0 +1,68 @@
---
status: active
initiated: 2026-10-10
touches:
- 02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md
- 02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md
- 02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md
- 02-DECISIONS/0268-the-login-shells-execute-is-withheld-on-the-control-node-until-a-call-to-it-needs-a-persons-approval.md
- 02-DECISIONS/0144-anything-on-a-machine-may-call-anything-on-it.md
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
- 04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md
- 01-RESEARCH/039-a-tool-that-needs-a-persons-approval/00-overview.md
became: []
---
# 041 — The agent's session runs confined, instead of being proven by a disk-wide search
## What
Whether the coding agent's session on a node with an **agent account** should be **confined when it
starts**, so that what it cannot do is enforced by the kernel, instead of the node-engine proving the
account harmless by searching every local filesystem for setuid programs. Three parts, from the
operator's idea of 2026-10-10:
1. **No new privileges.** The session and everything it starts run with the kernel's `no_new_privs` flag,
so a setuid or setgid program, packaged or not, and a program with file capabilities, grant it nothing.
2. **A confined view of the files.** The session sees `/usr` read-only, its own home, a private `/tmp` and
the few system files a program needs to run, and nothing else.
3. **Everything else through the mesh's tools.** What an agent reads outside that view (a file, a
directory, a log) is a tool of a seat, not a path.
And then: what must be **proven** for a node to be root-free under such a start (every way into the
account goes through it), how the node-engine checks that cheaply, and what becomes of the setuid search.
## Why
[ADR 0266](../../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md)
§3 item 10 judges the agent account by a search of every local filesystem mounted without `nosuid` for
setuid or setgid root programs no package owns, bounded at 15 minutes. Until a search finishes, the verdict
is *not judged*, and [ADR 0259](../../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md)
§8 rule 2 counts a machine without a recent verdict as not root-free, so the router approves nothing proven
there and a channel's `verified-sender` is stripped.
Measured on 2026-10-10 (the controller's `root-free` verb and `node` verb): on the control-node the search
"did not finish within 15m0s", the agent account is *not judged*, and two urgent conditions are open
(`agent-root` and `root-not-free`). Linking the operator's phone channel is blocked by it.
[Issue 344](../../04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md) gap 9 had
foreseen it ("on a large data pool the root search may never finish"). A resumable search is the short-term
fix, tracked separately. This effort asks whether the search is the right instrument at all: it answers
"is there a program that could make this account root?", while the question ADR 0259 needs is "can an
agent's session become root?". A confined start answers the second question directly.
## What it touches
- **ADR 0266 §3 and §4**: which ways to root the node-engine judges, and what a root-free verdict rests on.
Under `no_new_privs` item 10 (unpackaged setuid) and the *not judged* file capabilities no longer
apply to the session; new things must be proven instead (§3 of the findings).
- **ADR 0266 §7 and to-be 36 §9**: `claude-agent` becomes the confined start, not only an account switch.
- **ADR 0259 §8**: `root-free` would read the new proof.
- **ADR 0245**: the guard stays as the session's own refusal; the confinement backs it in the kernel.
- **ADR 0144, ADR 0152 and ADR 0266 §7**: the mesh MCP server has no login of its own, so a confined agent
still reaches every tool the operator reaches. Not closed here; recorded as the boundary of this effort.
- **Issue 344** gaps 1, 3, 4 and 9, and **research 039** (a tool that needs a person's approval).
- **A node filesystem seat** that does not exist today.
## Documents
- [01 — The options, what must be proven, and the open questions](01-options-and-open-questions.md)
@@ -0,0 +1,184 @@
# 041 — The options, what must be proven, and the open questions
Sources: [ADR 0266](../../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md)
§3, §4, §6 and §7; [ADR 0259](../../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md)
§8; [to-be 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §9;
[issue 344](../../04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md);
[ADR 0144](../../02-DECISIONS/0144-anything-on-a-machine-may-call-anything-on-it.md) and
[ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md) (the machine's login is
the authority on loopback). The records module was searched on 2026-10-10 for `setuid`, `not judged yet`,
`loopback` and `cannot tell the operator from an agent`; the hits are the records above.
`no_new_privs`, `NoNewPrivileges`, `Landlock`, `bubblewrap` and `ProtectSystem` appear in no record.
What the kernel and the tools do is stated from their documentation and has **not been measured on any node**
yet. Each such claim is marked *to measure* where the decision would rest on it.
## 1. What the setuid search answers, and what it does not
ADR 0266 §3 judges eleven ways to root by reading the machine. Item 10, the setuid search, is the only one
whose cost grows with the data on the machine. On the control-node it did not finish within its 15-minute
bound (measured 2026-10-10, `mesh-controller.root-free`), so the verdict is *not judged* and ADR 0259 §8
blocks every approval proven there.
The search asks whether a program exists that could make the account root. It does not ask whether the
agent's session can run one. Even when it finishes, ADR 0266 lists what it leaves unseen: a packaged setuid
program with a flaw of its own, file capabilities on a program, setuid programs on filesystems not searched.
## 2. The three parts, compared
### 2.1 No new privileges
`no_new_privs` is a per-process flag (Linux 3.5 and later). It is set by `prctl(PR_SET_NO_NEW_PRIVS)`, by
`setpriv --no-new-privs`, or by the service manager's `NoNewPrivileges=yes`. Any process may set it, without
privilege. Once set it is inherited by every child and cannot be cleared. Under it, `execve` grants no
setuid, no setgid and no file capability.
| Way to root (ADR 0266 §3) | Under `no_new_privs` |
|---|---|
| 3, 4: a sudo or doas rule | inert: `sudo` and `doas` are setuid programs |
| 5, 6: polkit through `pkexec` | inert (setuid); polkit **through D-Bus** to a root daemon is not touched |
| 7, 8: a container runtime's socket or TCP port | not touched: the request goes to a root daemon |
| 10: unpackaged setuid program | inert, on every filesystem, searched or not |
| *not judged*: file capabilities, a flawed packaged setuid program | inert |
| *not judged*: a root-run file the account can write; root's keys | not touched |
So it removes the whole class "a program that gains privilege when executed", and leaves the class
"**a root daemon that does something when asked**": the container runtime, the service manager over D-Bus
(polkit), the ssh daemon (a key into another account), cron and at, and any file root later reads.
**It has an escape that must be closed: a process the session asks to start something.** The flag passes
only from parent to child. If the agent account has a running user manager, `systemd-run --user` starts a
command as a child of that manager, which root started without the flag. The same holds for a cron or at
daemon, for D-Bus activation on a session bus, and for a terminal multiplexer server started before the
confined session. Either the account has none of these, or each is itself started with the flag (a drop-in
on the account's `user@` unit can set `NoNewPrivileges=yes`; *to measure*).
### 2.2 A confined view of the files
| | Service manager unit (`ProtectSystem=strict`, `ProtectHome=tmpfs`, `BindPaths=`, `PrivateTmp=`, `TemporaryFileSystem=`, `InaccessiblePaths=`) | Landlock | bubblewrap |
|---|---|---|---|
| Kernel | mount namespaces, present on every kernel the mesh runs | an LSM, 5.13 or later, and enabled in the kernel's LSM list; features grow by ABI (5.19, 6.2, 6.7, 6.10, 6.12) — *to measure on each node* | user namespaces; refused where unprivileged user namespaces are switched off |
| Who sets it up | root (a system unit, or `systemd-run` as root with `User=`); `claude-agent` already goes through the operator's sudo | the process itself, unprivileged, and it requires `no_new_privs` first | the process itself, unprivileged where user namespaces are allowed; sets `no_new_privs` itself |
| Shape | hides or makes read-only whole trees; can show only a list of paths | an allow list of path rights, checked on access; nothing is hidden from a directory listing above an allowed path | a new mount tree built from bind mounts: only what is listed exists |
| What the node-engine can read | the unit's properties from the service manager, and the session's processes' cgroup | `/proc/<pid>/status` shows `NoNewPrivs`; whether a Landlock domain is applied is not readable from outside on older kernels — *to measure* | `NoNewPrivs` and the mount namespace in `/proc`; the bind list is not declared anywhere the engine reads |
| Also gives | `RestrictSUIDSGID=`, `SystemCallFilter=`, `RestrictNamespaces=`, resource limits, one cgroup for every process of the session | per-process, stackable; can be added inside any of the other two | Claude Code's own sandbox on Linux is built on it |
| Interactive terminal | `systemd-run --pty`; *to measure* with `use_pty` from the `sudo` module | unchanged | unchanged |
**Claude Code's own sandbox** (from its documentation, *to measure* on the installed version): on Linux it
runs the shell tool's commands, and their children, under bubblewrap with a write allow list and a network
proxy. It does **not** cover the session's file tools (read, edit, write), which permission rules govern,
nor the session's MCP servers. The shell tool also takes a per-call override that runs a command outside the
sandbox, which a setting can forbid. So it confines the commands an agent types, not the session, and it is
configured by the session's own settings. It is a useful inner layer; it is not the boundary ADR 0259 can
rest on.
**Composability.** The three nest: a unit started by root gives the outer view and `no_new_privs`; Landlock
can narrow further inside it; Claude Code's bubblewrap runs inside both if the unit allows user namespaces
(`RestrictNamespaces=` would forbid it, and with it a class of kernel exploits; a trade-off, see §5). The
guard ([ADR 0245](../../02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md))
runs inside the session as a hook and keeps refusing; the confinement makes a refusal it misses fail in the
kernel. The tool runner runs outside, as the operator account, and the session reaches it over loopback, so
the confinement must leave the loopback network open.
**"Nothing else" is a short list, not nothing.** A program needs the dynamic linker's cache, the account
database, the resolver file, the certificate store (the agent talks to its API over TLS), `/proc`, `/dev/null`
and its terminal. Which `/etc` files the agent needs, and whether `/etc` is read-only or hidden but for them,
is to be listed by trying it.
### 2.3 Everything else through the mesh's tools
Measured on 2026-10-10 with `mesh_overview` and `mesh_search`: every node holds `node-service-manager`, whose
`journal` verb reads one unit's journal, and `node-hostname`, whose `entries` verb reads the machine's `/etc/hosts`.
**No seat reads an arbitrary file, lists a directory or reads a log file that is not a unit's journal.**
Today that is done through `node-login-shell.execute`, which runs any command as the operator account, and
which the control-node withholds ([ADR 0268](../../02-DECISIONS/0268-the-login-shells-execute-is-withheld-on-the-control-node-until-a-call-to-it-needs-a-persons-approval.md)).
A node filesystem seat cannot simply read a caller's path: the tool runner acts for any caller, and ADR 0266
§7 calls a tool that reads a caller's path as the operator or as root a defect. A shape that would hold
follows the store's reader after issue 337: it reads only what a module **declares readable** (its
configuration, its logs), never a secret, through no symbolic link. Verbs it needs, at least: read a declared
file, list a declared directory, read a declared log's tail with a filter.
On the operator's machines the agent works in the operator's desktop and files (ADR 0266 §1). Moving it to an
agent account there is only possible once such tools, and the desk's own tools, cover that work, and issue
344 gaps 3 and 4 (the display, the chat client) need the agent account to have no access to the operator's
display at all. Confining the agent while it **stays** the operator account does not hold: it can write a
file the operator later runs with sudo (a shell start file), and the operator's own user manager starts
anything without the flag.
## 3. What must then be proven for root-free
A confined start proves nothing if another way into the account exists. The proof becomes two halves.
**The doors (read from the machine's configuration, cheap).**
1. The account has no password: a locked entry in the shadow database.
2. No ssh key logs into it: no `authorized_keys` under its home, or the ssh daemon refuses the account.
3. No cron table and no at job for it, or no cron or at daemon runs.
4. No lingering user manager, and no user units under its home; or its `user@` unit carries
`NoNewPrivileges=yes` and the same confinement.
5. Sudo rules only **toward** it (the operator to the agent account, through `claude-agent`), none from it.
ADR 0266 §3 item 3 already judges the second half.
6. `claude-agent` starts the session as the confined unit, and the unit's declaration is the mesh's.
**The session itself (read from `/proc`, cheap, and closer to the truth than the doors).**
7. Every process running as the agent account has `NoNewPrivs: 1` in its status and sits in the cgroup of
a confined session unit. A process that does not is a door nobody listed, found by its effect.
**The daemons that still act on request** (ADR 0266 §3 items 5 to 9 and part of the *not judged* list) stay
judged as today, because `no_new_privs` does not reach them. The confined view can also hide their entry
points: the container runtime's socket and the system bus can be made inaccessible to the session, which a
unit property states and the engine reads.
Every one of these reads is bounded by the number of processes and a handful of files, not by the size of the
disks. Item 7 is checked at a moment; a process started between two looks is seen at the next one, within the
engine's interval.
**What becomes of the setuid search.** Three choices, for the operator (Q2):
- **Retired for agent accounts** once 1 to 7 hold: it judges nothing a confined session can use.
- **Kept as defence in depth**, resumable and unbounded, with a finding raising a warning but no longer
gating `root-free`.
- **Kept only for accounts that are not confined**: the operator account where agents still run as it, and
any account whose processes fail item 7.
## 4. What this does not cover
- **The mesh MCP server has no login of its own** (ADR 0144, ADR 0152: the machine's login is the authority
on loopback; ADR 0266 §7: the tool runner acts for any caller). A confined agent reaches the same tools as
the operator. Every tool that runs a caller's command or reads a caller's path as another account remains a
way round, as ADR 0266 §7 and issue 344 gaps 2, 5 and 11 already record. Research
[039](../039-a-tool-that-needs-a-persons-approval/00-overview.md) is the work that narrows it.
- **A kernel exploit.** `no_new_privs` and a confined view do not stop a flaw in the kernel. A system call
filter and no new user namespaces shrink the surface; the second conflicts with Claude Code's bubblewrap
sandbox (§2.2). The residual risk is the kernel's, as for every other process on the machine.
- **Network reach.** The session keeps the network it needs for its API and the loopback MCP server; a
network allow list is a separate question.
## 5. Open questions for the operator
1. **Is the confined start the boundary ADR 0259 rests on**, replacing the setuid search as the gate for
`root-free` on a node with an agent account? Recommendation: yes, because it answers "can the session
become root" instead of "does a program exist", and its cost does not grow with the data on the machine.
2. **What becomes of the setuid search** (§3)? Recommendation: keep it resumable as defence in depth for a
warning, gate `root-free` only on unconfined accounts.
3. **Which mechanism carries the confinement?** Recommendation: a service manager unit started by root
through `claude-agent`, because it needs no new package, gives `no_new_privs` and the view in one
declaration the node-engine can read; Landlock as an optional inner layer where the kernel has it; Claude
Code's bubblewrap sandbox kept for the shell tool, with its per-call override forbidden.
4. **May the session keep user namespaces**, so that Claude Code's sandbox works inside it, at the price of
a larger kernel surface? Or is the outer unit enough and the inner sandbox dropped?
5. **Is a node filesystem seat that reads only declared paths enough** for what agents do on the
control-node, and is it the precondition for moving the operator's machines to an agent account?
## 6. Next measurements
- On each node: the kernel's LSM list and Landlock ABI, whether unprivileged user namespaces are allowed, and
whether `sudo -iu` there starts a user manager for the target account. No mesh tool reads the first two
today; that is itself a gap for the node-engine's facts.
- A trial `claude-agent` as a transient unit on a non-control node: which `/etc` files the session needs, that
`sudo -n true` fails with the flag set, that `systemd-run --user` is refused or inherits the flag, and that
the mesh MCP server and the guard still answer.
- The `/proc` sweep of item 7 on the control-node: how many processes run as the agent account, and how long
the read takes.
@@ -0,0 +1,86 @@
---
status: graduated
initiated: 2026-10-10
touches:
- 02-DECISIONS/0019-how-this-repository-works.md
- 02-DECISIONS/0080-the-development-cycle-is-checked.md
- 00-META/process/03-issues.md
- 00-META/glossary.md
- the runtime's state verbs and the SDK's state interface
became:
- 03-DESIGN/01-to-be/53-the-issue-tracker.md
- 02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md
---
# 042 — The mesh keeps its own issues
## What
Whether the mesh should keep its issues in a module of its own, on the bus, instead of only as
numbered folders in this repository's `04-ISSUES/`, and what the smallest useful first version of
that module is. The operator's ask of 2026-10-10: work on a ticketing system on the bus, consolidate
every current issue into it, start small and extend the module along the way.
The effort answers six questions, in [01-findings.md](01-findings.md):
1. The smallest useful first slice: what the module holds, which tools it serves, which events it
emits, and what it explicitly leaves out.
2. Where the truth lives during and after the move: the folders here, the module, or a split
between them, and what each choice does to the public-repository rule, playbook 03 and the hq
checks.
3. How the 357 issue folders are imported and consolidated (duplicates, still-open ones, grouping).
4. How the controller's conditions, the router's asks and deliveries relate to issues.
5. How the operator sees issues: through the mesh's tools first, through a view onto the bus later.
6. The open questions for the operator, and the slices after the first.
The operator also named an existing project as inspiration: *hits*, a headless issue tracker built
as NATS micro services on JetStream, with an MCP server for agents. Its model is studied in the
findings, along with whether to reuse its code or build on its model.
## Why
Numbers collide. Playbook 03 tells an author to take the next free number across `main` and every
open pull request, and [issue 155](../../04-ISSUES/155-two-records-may-share-a-number-and-nothing-says-so/00-report.md)
records the second collision reaching `main` with every check passing. `cycle.py` now refuses two
records sharing a number, which catches a collision after it happens and does not prevent one. On
`main` at the time of writing, 31 commits mention a renumbering. On 2026-10-07 three decision numbers
collided in one evening. On 2026-10-10 an open pull request still carries a branch named for issue
349 and a title naming issue 350, because 349 was taken on `main` first. The operator's answer on
2026-10-07 was that numbering belongs to the mesh's own issue tracker.
Status is also hard to see. Of 357 issue folders on `main`, 73 are not closed (27 `open`, 45
`located`, 1 `diagnosing`). Finding them means reading frontmatter across the repository.
Nothing connects a condition the controller raises to the issue that would fix it: on 2026-10-10 a
condition was silenced with the words "hq issue to follow", and nothing records whether one followed.
## What it touches
- [ADR 0019](../../02-DECISIONS/0019-how-this-repository-works.md) (statuses live in frontmatter) and
[ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md) (the cycle is checked), with
playbook [03](../../00-META/process/03-issues.md) and `00-META/checks/cycle.py`.
- [ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)
(a module keeps its state in key-value buckets it declares),
[ADR 0197](../../02-DECISIONS/0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)
(tools announce themselves on the bus) and
[ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) (events are declared).
- The conditions of [ADR 0231](../../02-DECISIONS/0231-a-healer-acts-on-what-observation-raised-and-only-observation-says-it-worked.md)
and the asks of [ADR 0259](../../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md).
- The records module, which indexes this repository for agents.
**Graduated 2026-10-10** into [ADR 0275](../../02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md)
and [to-be 53](../../03-DESIGN/01-to-be/53-the-issue-tracker.md). The operator delegated the five open questions; each
was decided as recommended. `touches:` names what the decision changes.
## Words
The glossary's word for one of these is **issue** (*The record*: "a report in `04-ISSUES/`"). This
effort keeps that word for the thing the module holds, and proposes **issue tracker** for the module
that holds them, with the module named `issues`. It does not use *ticket*, the operator's word in
the ask, or *item*, the word hits uses. If the glossary entry is widened (an issue is then held by
the issue tracker, and its narrative may live in `04-ISSUES/`), *ticket* would go on its *Not:* line.
That change belongs to the decision this effort graduates into, not to the effort itself.
## Open questions for the operator
Listed in [01-findings.md § 7](01-findings.md#7-open-questions-for-the-operator), each with a
recommendation.
@@ -0,0 +1,259 @@
# 01 — Findings
Measured on 2026-10-10 against `main` of this repository, the open pull requests on the forge, the
controller's open conditions, the module SDK's source, and a clone of the hits repository at its
release v0.7.0. Each section ends with what this effort recommends; section 7 lists what needs the
operator.
## 0. What exists today, measured
| Fact | Measured |
|---|---|
| Issue folders on `main` | 357, numbered up to 362; numbers 283 and 284 were never used, and 338, 341 and 350 sit on open pull requests (363 too) |
| Status in frontmatter | 283 `resolved`, 1 `wontfix`, 45 `located`, 27 `open`, 1 `diagnosing` |
| Frontmatter keys | `status`, `opened`, `located-in` on all 357; `fixed-by` 356; `amended-design` 344; `replay` 22; `replay-none` 44 |
| Size | 1.9 MiB of markdown in all; median folder 4.6 KiB, largest 25 KiB; 244 folders hold only a report, 111 also a diagnosis |
| Links between issues | 168 folders link another issue's folder (265 links); 193 name another issue in prose |
| `located-in` | free text: the controller is written `mesh-controller`, `cmd/mesh-controller`, `mesh-control` and as file paths; 17 issues name nothing |
| Renumberings | 31 commits on `main` mention one; one open pull request still carries the old number in its branch name |
| The forge's own issues | none in this repository or the controller's; [issue 178](../../04-ISSUES/178-a-routed-name-resolves-to-a-provider-merely-told-it/00-report.md) was filed there once and moved here, because "its numbers collide with hq's pull request numbers in conversation" |
| Conditions | 10 open; 5 of kind `healer-wanted` (a hand repair repeated, "an automatic repair is wanted"); one silenced with "hq issue to follow" |
Two things follow. The collisions come from allocating a number by reading a repository that many
branches write at once; nothing short of one allocator removes them. And the frontmatter is a small,
regular data set (six keys, 1.9 MiB) that fits easily in the bus's key-value store under the limits
[ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)
§6 sets (256 KiB a value, 64 MiB a bucket).
## 1. The first slice
**A Go module `issues`, one instance on the control-node, serving one seat held once for the mesh.**
Its state is in buckets it declares (ADR 0201), its tools announce themselves on the bus
([ADR 0197](../../02-DECISIONS/0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)),
and every change is an event it declares under `emits`
([ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md)). The module that delivers pull
requests already has this shape: Go, one mesh seat, two state buckets, declared events.
**What one issue holds in v1**, the frontmatter of today plus what the folders carry in prose:
- `number` (the key), `title`, `status` (`open`, `diagnosing`, `located`, `resolved`, `wontfix`),
`opened`, `closed`;
- `located-in`, from a registered list of repositories and modules rather than free text;
- `fixed-by` (pull requests or commits), `amended-design`, `replay` or `replay-none`;
- `notes`, appended and never edited: who wrote, when, and the text;
- `links`, typed: to another issue (`duplicates`, `relates-to`), to a pull request, a decision
record, a design, a condition key or a delivery;
- `record`: the path of the issue's folder in this repository, when it has one (section 2).
**Storage.** One bucket `issues` with one key per issue, keeping a short history per key, and a
`counter` key for the next number. The events (`opened`, `changed`, `noted`, `linked`) carry what
changed and who changed it, so they are the audit trail, and a view can watch the bucket. Section 6
compares this with a stream as the source of record.
**Tools** (verbs of the seat, found with `mesh_search`):
| Tool | Does |
|---|---|
| `open` | takes the next number atomically and writes the issue; refuses a title or report that is empty |
| `get` | one issue, whole |
| `list` | by status, by `located-in`, by a phrase in title or notes; compact rows, newest first |
| `move` | changes status, enforcing today's cycle rules at the write: `located` needs `located-in`; `resolved` needs `fixed-by`; a core issue needs `replay` or `replay-none`; `wontfix` needs a reason |
| `note` | appends to the trail |
| `link` / `unlink` | adds or removes a typed link |
| `import` | reads this repository's issue folders and writes them as issues with their own numbers (section 3); idempotent, reporting what it would change before it does |
**The caller is the bus credential, not an argument.** The module records who acted from the
identity the runtime gives it with each call, so an agent cannot write as someone else.
**Atomic numbering needs compare-and-set.** The SDK's state interface (v0.1.9) offers `get`, `put`,
`delete`, `keys` and `watch`, and no write that is refused when the key changed since it was read.
Whether the runtime's `mesh/state.put` takes an expected revision was not checked; the tool runner's
code was not read for this effort. Two ways out: add `create` and an `update` that takes the revision
read to the state interface (small, and every module with a counter gains it), or run exactly one
instance and number inside it. **Recommended: the first.** One instance is also right for v1, but the
counter should not depend on it.
> **Correction — 2026-10-10.** The finding above is wrong about the SDK. Version v0.1.9, the one read, had no
> compare-and-set, but the SDK's Go state interface already had it from go/v0.1.10: `Create` (writes a key only if it
> does not exist) and `Update` (writes only at the revision the caller read), with the runtime's matching state
> verbs, both from [ADR 0259](../../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md).
> What the tracker's counter needed and slice 1 added is `Increment`, in go/v0.1.12, which reads a counter and writes
> the next number with `Create` or `Update` at the revision it read, retrying a bounded number of times. The
> recommendation stands; the work it named was smaller than stated. ADR 0275 carries the same correction.
**Explicitly left out of v1:** decision and research numbers (slice 2), any change to playbook 03 or
the hq checks (slice 2), duplicate finding beyond links (slice 3), opening an issue from a condition
(slice 4), a web view (slice 5), full-text or semantic search, priorities, claims, notifications, and
the forge's own issues.
## 2. Where the truth lives
| | (a) Split: the tracker holds the issue, hq holds the narrative | (b) Everything moves | (c) The tracker mirrors hq |
|---|---|---|---|
| Number | tracker | tracker | hq, so collisions stay |
| Status, `located-in`, `fixed-by`, links | tracker | tracker | hq frontmatter, copied |
| Report and diagnosis | hq folder for a design or governance question; notes in the tracker for the rest | tracker notes | hq |
| Public-repository rule | holds: hq keeps only scrubbed narrative, the tracker is private and may name nodes | the public record of design questions is lost | holds |
| Playbook 03 | changes: open in the tracker first, write a folder only for a design or governance question | replaced | unchanged |
| `cycle.py` | its issue rules move into the tracker's `move`, refused at the write instead of at merge | same | unchanged, and a second copy to keep in step |
| Records module | still finds the narrative; the tracker's `list` and `get` serve status | finds no issues | unchanged |
Option (c) keeps the collisions and adds a second copy that can drift, so it answers neither of the
operator's problems. Option (b) loses the public record that hq exists to be
([ADR 0019](../../02-DECISIONS/0019-how-this-repository-works.md)), and with it the
symptom-to-component memory that playbook 03 says is never deleted.
**Recommended: (a).** Every issue gets a number from the tracker, including ones that never need a
document here (a node's quirk, an operational follow-up, which playbook 03's table already sends
elsewhere). A design or governance issue also gets a folder `04-ISSUES/NNN-slug/` with the tracker's
number, whose frontmatter shrinks to a pointer (`issue: NNN`) and whose prose stays reviewed in pull
requests. Status lives in one place, the tracker. This reverses ADR 0019's "statuses live only in
frontmatter" for issues and moves part of ADR 0080's check, so it needs a decision record that
supersedes those parts, written when this effort graduates.
**Numbering continues.** The tracker's counter starts above the highest number on `main` and on open
branches at the moment of the switch (today 363, so the first new issue is 364). Numbers 283 and 284
stay unused, and a number never moves again.
**During the move**, hq frontmatter stays authoritative until the import has run and been checked
against it, and one pull request then removes the status keys from every report and changes playbook
03 and `cycle.py` together. There is no period in which both are written.
## 3. Importing and consolidating the 357 issues
**Import.** `import` reads the folders through the records module (`records_list`, `records_read`),
over the bus, rather than from a checkout. Each folder becomes an issue with its own number, its
frontmatter as fields, its title from the heading, the commit it was read at, and its path as
`record`. Folder-to-folder links (265) become `relates-to` links. The four open pull requests'
issues (338, 341, 350, 363) are reserved as numbers and imported when they merge.
**Consolidating** means four concrete passes, each producing a list for the operator or an agent to
confirm, never applying itself:
1. **Close what is fixed.** A `located` or `open` issue whose `located-in` repository has a merged
pull request naming its number is a candidate for `resolved`. hits's auditor (section 6) checks
exactly this against git and is the model.
2. **Merge duplicates.** Candidates are pairs that link each other and share a `located-in`, and
pairs whose titles share most words. A confirmed duplicate is closed `wontfix` with a
`duplicates` link and the reason, keeping its number and folder.
3. **Normalise `located-in`** onto the registered list (`mesh-controller`, `mesh-catalog`, the
node-engine's repository, a module's name), keeping the file paths as a note.
4. **Group the 73 not closed** by domain (the glossary's domains, ADR 0244) and by `located-in`, so
the operator sees, for example, how many open issues the delivery domain carries.
## 4. Conditions, asks and deliveries
A condition is one open fact, raised and cleared only by observation (ADR 0231); an issue is a
question someone must answer. They are different things, and a condition must not be closed by
closing an issue. But a condition can say that a fix is owed: five of today's ten are `healer-wanted`,
and one silence says "hq issue to follow".
Recommended for a later slice (slice 4): an issue links a condition by its key; the controller shows
the link on the condition; and for `healer-wanted` the controller opens one issue per condition key
when it first raises it, linked both ways, instead of an agent remembering to. A silence whose reason
names an owed issue should carry its number. Asks
([ADR 0259](../../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md))
stay with the router: the tracker asks nothing in v1. Deliveries relate through pull requests: when
the forge says a pull request merged, the tracker notes it on every issue the pull request names, and
offers `resolved` (slice 4). Nothing in v1 depends on these.
## 5. How the operator sees issues
First through the mesh's tools: `list` returns rows short enough to read in a chat, `get` one issue
whole, and every agent session reaches them through the mesh MCP server. The responder (`@mesh`,
ADR 0234) answers from read verbs, so "what is open in delivery" works on a channel once the seat
exists. Later a view onto the bus: the `issues` bucket watched over the bus (a browser reaches it through a WebSocket listener, which this effort did not check for),
read-only, which the operator named as a future direction for every module's state. Nothing in v1
blocks it, because the state is already a bucket and every change an event.
## 6. hits: what it is, and what to take from it
**What it is.** A headless issue tracker in Go (module `github.com/impire-io/hits`), with no web
interface and no HTTP interface: NATS micro services on JetStream, a command line for people and a
stdio MCP server for agents. One author, 81 commits between 2026-09-02 and 2026-09-14, release
v0.7.0, about 9,700 lines of Go outside tests and 21 test files, tested against an embedded NATS
server. Its model descends from a method like this repository's: a sibling `hits-hq` with numbered
playbooks and decisions.
**Data model.** An *item* has a type (`bug`, `task`, `improvement`), a status (`open`, `diagnosing`,
`located`, `blocked`, `resolved`, `wontfix`), a priority, `located-in` (from a registered list of
*projects*), `fixed-by` (pull request, commit or action), `amended-design`, append-only notes, typed
links (`duplicates`, `relates-to`), a claim, a block, and a tombstone for a filing mistake. Its
statuses and fields are this repository's frontmatter almost key for key. Projects, *initiatives*
(groups of projects that also scope the numbering) and *releases* are registered lists that are
never reused once retired.
**How it stores.** The source of record is a stream `hits-ops` with subjects
`hits.ops.item.<id>` (and `project`, `initiative`, `release`). Every write appends an operation,
published with the per-subject expected last sequence, so two writers to one item cannot both win.
A bucket `hits-state` holds the folded snapshot of each item, the registered lists and one counter
per initiative. The counter is advanced by compare-and-set and an item is numbered only after it is
validated, so a refused create spends no number. On start it rebuilds the bucket from the stream.
**Services and subjects.** One micro service `hits` with 23 endpoints under `hits.api.*` (`create`,
`get`, `edit`, `transition`, `claim`, `release`, `block`, `unblock`, `link`, `unlink`, `note`,
`tombstone`, and register, list and retire for projects, initiatives and releases); three index
services, each a projection of the stream: full text (`hits.search.query`, a local index on disk),
a graph (`hits.graph.neighbors`, `hits.graph.walk`) and an optional semantic search that needs an
outside embedding provider.
**MCP tools.** 27, one per endpoint: `create_item`, `get_item`, `edit_item`, `transition_item`,
`claim_item`, `release_item`, `block_item`, `unblock_item`, `link_items`, `unlink_items`,
`note_item`, `tombstone_item`, the project, initiative and release tools, and `search_items`,
`semantic_search`, `graph_neighbors`, `graph_walk`. One server serves one agent under one actor.
Its command line also has an auditor that checks every item's `fixed-by` against git and finds
merged pull requests whose items are still open.
| | Adopt as it is | Adapt to the mesh |
|---|---|---|
| Statuses, fields, append-only notes, typed links, tombstone | yes: they are already ours | — |
| Stream of operations as the record, bucket as its projection | the pattern | the stream and bucket declared in the manifest and created by the controller (ADR 0201 §3), not created by the service on start |
| Expected-sequence writes; compare-and-set counter; number only after validation | yes | needs compare-and-set in the state interface (section 1) |
| The auditor | the idea, as a tool | reads the forge through the gitea module, not a local clone |
| Subjects `hits.api.*`, its own MCP server | no | the seat's verbs, served by the tool runner and found with `mesh_search` |
| Actor named by the caller | no | the caller is the bus credential the mesh issued |
| Contexts, OAuth device flow, semantic search | no | the mesh issues credentials; no outside provider |
| *item*, *project*, *initiative*, *release* | no | *issue*; a repository or module; no initiatives in v1; *release* is already a homonym here (glossary) |
**Reuse its code, or build on its model?** Its licence is the Sustainable Use License 1.0: use and
modification are allowed for internal business, non-commercial or personal use, and distribution only
free of charge and for non-commercial purposes. It is not an open-source licence. Running it on a
personal mesh is allowed; vendoring it into the catalogue binds the catalogue to that licence for as
long as the code is there. Its services create their own stream and bucket on start, which ADR 0201
§3 forbids a module to do; its subjects carry no prefix and assume one tracker per bus account; its
words clash with the glossary. Changing those means a fork of a two-week-old, single-author project.
v1 here is the item store without the indexes: roughly the size of the module that delivers pull
requests.
**Recommended: build our own module on hits's model,** and say so in the module's README. Do not
depend on or vendor its code. Revisit only if its licence changes, or if its author agrees to a
licence for this use.
## 7. Open questions for the operator
1. **Should the tracker hold every issue's number and status, while hq keeps a folder only for design
and governance issues, pointing at the tracker by number (option (a) in section 2)?**
Recommended: yes. It ends the collisions, keeps the public record, and moves the cycle rules into
the write, where they refuse instead of report.
2. **Should we build our own `issues` module on hits's model rather than run or vendor hits?**
Recommended: build our own. hits's licence limits distribution, its services create their own bus
resources (against ADR 0201), and its words clash with ours. Its model, its compare-and-set
numbering and its auditor are worth copying.
3. **Should the tracker also hand out decision and research numbers?** Recommended: yes, in slice 2.
Decision numbers collided three times on 2026-10-07; the same counter mechanism serves any
sequence, and the tracker then reserves the number when a branch starts.
4. **Should the word stay *issue*, with the module named `issues` and *ticket* retired?**
Recommended: yes. *Issue* is the glossary's word for this, and one word per thing (ADR 0244).
5. **Should the controller open an issue by itself for a `healer-wanted` condition, or only link
one an agent or the operator opened?** Recommended: link only, until slice 4 has run for a week;
then open by itself for `healer-wanted`, one issue per condition key.
## 8. Slices after the first
| Slice | Delivers | Done when the operator sees |
|---|---|---|
| 1 | The `issues` module: open, get, list, move, note, link, import; compare-and-set in the state interface | the next issue number comes from the tracker and the 73 not closed are listed by one call |
| 2 | Decision and research numbers from the tracker; playbook 03 and `cycle.py` changed; status keys removed from every report | two agents opening at once get two numbers, and no hq pull request is renumbered |
| 3 | Consolidation: the four passes of section 3, and an audit tool for `fixed-by` against the forge | the not-closed list shrinks by what was already fixed or duplicated, each with its reason |
| 4 | Conditions and pull requests linked; `healer-wanted` opens an issue | a condition shows its issue, and a merge notes the issues it names |
| 5 | A read-only view onto the `issues` bucket | the operator reads issues in a browser without an agent |
@@ -38,6 +38,11 @@ hand.
### What a domain module turns out to be, and why it is not the one refused above
> **Narrowed, not replaced — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The catalogue no longer
> holds a domain module: `networking` was retired, and every machine is assigned the private network's
> own module. A module with requirements and no files stays a thing a module may be; "why it is worth
> having" below describes what this record decided for `networking`, not what runs.
*Written 2026-08-29, from building it. The heading above reads as a contradiction of what now
exists and is not one — but only if the difference is stated, so it is stated here.*
@@ -8,6 +8,13 @@ reconstructed: false
# 19. How this repository works
> **Superseded in part — 2026-10-07, by [ADR 0248](0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md).**
> The paragraph "the index is written, not only generated on demand" no longer holds: a stored list was
> a line every decision pull request added to one file, so two open at once always conflicted (issue
> 297). The list of records is generated when it is read, and `02-DECISIONS/README.md` writes only the
> topics in their reading order. Everything else here stands, including the reading order and the check
> that every record has a topic one of the README's.
*Consolidated 2026-08-28 from ten records that were one decision seen from ten angles. The
reasoning is kept; the fragmentation is not.*
@@ -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
+2
View File
@@ -78,6 +78,8 @@ capability. The host hardcodes no firewall, supervisor, package manager or runti
generic apply primitives and platform detection, so it runs where none of those exist — an Android
phone has no ufw, systemd, pacman or Docker.
> **The mechanism changed — 2026-10-02, by [ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md).** The shell example above — *bash, zsh and fish all join `shell`; one may be default* — is read as *installed is not holding*: the three may all be installed, and the `login-shell` seat is node-scoped and held by exactly one. The decision — what a module is, and the three relationships — stands; [ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) applies it to the operator's whole machine.
## Consequences
- **Supersedes the earlier "grouped by domain" decision** (folded in consolidation; see the
@@ -9,6 +9,10 @@ extends: 0027-a-provision-names-what-the-consumer-is-coupled-to.md
# 44. A public name is provisioned, not registered by hand
> **The mechanism changed — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The interface stands; its one provider,
> `cloudflare-dns`, left the catalogue, assigned nowhere and required by nothing. A mesh that needs a
> public name made for it adds a provider of `public-dns` again.
## Context
The mesh names and resolves its own machines internally: the overlay generates
@@ -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
@@ -108,6 +108,14 @@ declared slug is a strictly better escape hatch than an opaque hash. **D stays r
## Consequences (of E)
> **The mechanism changed — 2026-10-06, by [ADR 0225](0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md).**
> Option C, named above as the later refinement, is taken: each offer states the longest identity its
> backend keeps, and a consumer is bounded by the provision it requires rather than by 20 everywhere.
> 20 stays the bound of the object store and of a provider that is told its consumers and does not
> say. The overflow is refused before merge by the catalogue check, and at composition the consumer
> is left out of its provider's grants and reported — never the provider's machine refused. What
> stands: the identity is said once, the slug is the remedy, nothing is hashed or truncated.
- A module manifest gains an optional `slug`; a node may carry one too. `ConsumerIdentity` prefers
the slug over the cleaned name for each half. `identityLimit` becomes 20 (the true minimum), and
`CheckIdentity` refuses at `module add` / assignment — now with a message naming the slug to set.

Some files were not shown because too many files have changed in this diff Show More