Compare commits

...
Author SHA1 Message Date
jochen f60fdd263f Issue 375: an agent cannot put a commit on the forge through the forge's tools, and the tools' other gaps
mesh/delivery ready: it delivers once merged
mesh/merge-gate out of date: judged against a trunk it does not name, the trunk is now a0e51c061b5e; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against a trunk it does not name, the trunk is now a0e51c061b5e; judged again on a recheck or when it is merged
2026-10-10 14:12:44 +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
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 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
75 changed files with 6054 additions and 155 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.
+24 -2
View File
@@ -4,8 +4,9 @@
python3 00-META/checks/records.py structure: links, citations, supersession, topics
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, and no word is defined twice
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.
@@ -61,7 +62,20 @@ number is how a record is cited, and `main` lags every open pull request, so two
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. `python3 00-META/checks/cycle.py`
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
@@ -75,6 +89,14 @@ never checked. It also fails when a glossary head word heads two entries or is a
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
+101
View File
@@ -56,6 +56,75 @@ CORE = re.compile(r"\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|
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)
@@ -233,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")))
+1
View File
@@ -14,3 +14,4 @@ list only shrinks.
| `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 |
+114 -13
View File
@@ -28,9 +28,23 @@ What is enforced:
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. Never 02-DECISIONS/: a decision record keeps the
words it was written with.
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
@@ -39,6 +53,7 @@ graduated research effort, which is a record of what was said, like a decision r
python3 00-META/checks/words.py
python3 00-META/checks/words.py --list tools
python3 00-META/checks/words.py --list lines
"""
import datetime
@@ -55,6 +70,25 @@ 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)
@@ -134,8 +168,8 @@ def prose(text, glossary_file=False):
return text
def research_and_issues():
"""The research efforts and issue reports dated on or after FROM, file by file."""
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)
@@ -144,7 +178,7 @@ def research_and_issues():
if not os.path.isfile(head):
continue
date = frontmatter_field(read(rel(head)), key)
if not date or date < FROM:
if not date or date < since:
continue
for name in sorted(os.listdir(os.path.join(base, effort))):
if name.endswith(".md"):
@@ -152,7 +186,7 @@ def research_and_issues():
return out
def checked_documents():
def checked_documents(since=FROM):
docs = []
for entry in ALWAYS:
path = os.path.join(ROOT, entry)
@@ -164,7 +198,7 @@ def checked_documents():
for name in sorted(files):
if name.endswith(".md"):
docs.append(rel(os.path.join(base, name)))
return docs + research_and_issues()
return docs + research_and_issues(since)
def allowed():
@@ -223,6 +257,60 @@ def check_retired(retired, identifiers):
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 = {}
@@ -253,29 +341,42 @@ def check_copy(retired):
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:
return ["the catalogue's retired-words differs from the glossary: it should list %s" % ", ".join(want)]
return []
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"
words = tools_list(retired) if scope == "tools" else sorted({w for w, s in retired if s in ("all", scope)})
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_copy(retired)
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 head words, each once"
% (len(retired), len(identifiers), len(heads)))
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
+59 -15
View File
@@ -135,14 +135,16 @@ every other domain has something to run on. **Recorded by** the controller and t
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
*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
*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~~
@@ -287,7 +289,7 @@ and the operator act. **Recorded by** the controller's `identity` and `licences`
**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.
**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**
@@ -324,6 +326,8 @@ planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
- **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),
@@ -343,10 +347,17 @@ planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
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 trunk commit's builds across nodes, tier by tier, one node
first and judged at the first-node gate, then the rest (ADR 0236). A primitive the delivering stage
- **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.
@@ -389,6 +400,10 @@ becomes a message). **Decided by** ADR 0227, 0231, 0240, 0252.
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).
@@ -443,10 +458,10 @@ it should not. **Recorded by** the controller. **Upstream of** Provisioning and
## 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 and the controller (authorising asks).
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, 0272.
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
@@ -462,7 +477,11 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0272.
([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.
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
@@ -476,10 +495,14 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0272.
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 answer performs an action; the controller
holds it and checks its proofs (ADR 0234).
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 the controller's list of the operator's identities: a **trusted** one is an operator message,
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.
@@ -487,11 +510,24 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0272.
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. **Decided by** ADR 0019, 0080, 0244.
**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
@@ -500,8 +536,14 @@ Not a domain of the mesh but of the way it is built, listed because its words me
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 report in `04-ISSUES/` of something wrong with the mesh at the level of design or
governance; an **incident** is a past event that taught a rule.
- **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
@@ -515,7 +557,7 @@ renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
|---|---|---|---|
| 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 commit across nodes | **walk** (verb `plans`) | 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 |
@@ -548,6 +590,8 @@ renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
| | 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 | — |
+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
+42 -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,15 +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,
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).
([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
@@ -57,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
@@ -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 |
@@ -9,6 +9,11 @@ extends: 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
# 162. A merge produces a tiered plan the mesh keeps, and a module's dependencies are one relation in the catalogue
> **Superseded in part — 2026-10-10, by [ADR 0276](0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md).**
> "A merge produces a tiered plan" no longer holds: merges are collected in a rolling merge window, and each
> batch is planned once, as one walk with at most one commit per repository (issue 362). The plan the mesh
> keeps, its tiers and the one dependency relation stand as decided here.
> **Progressive insight — 2026-10-02.** The context below says a dependent is *built by whichever build machine is running — the only one there could be*. That was a fact of the day, not of the decision: since [ADR 0190](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md) a tier's asks are taken by every machine holding the build seat. The plan and its tiers are unchanged.
> **Superseded in part — 2026-10-08, by [ADR 0267](0267-an-artifact-is-rebuilt-only-when-its-build-source-changes-and-a-module-moves-only-along-build-dependencies-on-artifacts.md).**
@@ -118,6 +118,17 @@ ADR 0213's setting, then the mesh scope, then the node scope, then the mesh's ow
state. It writes, changes and removes only those. It refuses to register a name the person already
uses there, rather than overwrite it.
> **The mechanism changed — 2026-10-09, by [ADR 0273](0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md).**
> What stands: one plugin, three scopes, registration through the module's tools and state on the bus (§1
> to §4). What moved: §5 and §6. The module now owns every configuration file under the agent's home,
> rendered from registrations, with the program's working state excluded by name; a file it did not render
> is a condition, `agent-home-drift`, rather than an item listed on request, and a hand edit of a rendered
> file is written back with the hand version kept. A fourth scope, the operator's, carries the operator's
> personal rules, skills, commands, subagents and settings into the agent's home on every node. Why: the
> homes measured on 2026-10-09 held a skill retired a week before on all four nodes and the operator's rules
> on one, and nothing was raised. Who may register into the operator's home is issue 340's rule, kept: the
> controller's terminal, or a warrant on an ask.
**6. What the module did not place, it reports and can import.**
- A status tool lists the home's items and says which the mesh placed. It also names any that
@@ -9,6 +9,12 @@ extends: 02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md
# 218. A plan sends grants before code, rolls a module out one machine first, and a newer merge takes over an older plan
> **Superseded in part — 2026-10-10, by [ADR 0276](0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md).**
> Decision 3 no longer holds: a newer merge does not take over an older plan. Merges are collected in a
> rolling merge window and walked one batch at a time; a walk that started is never superseded, and only a
> walk still waiting for its word is folded into the next batch's walk (issue 362). Grants before code and
> one machine first stand as decided here.
## Context
On 2026-10-05 the delivery path was watched through a day of merges, by several sessions at once. Three
@@ -289,6 +289,12 @@ consumers are later work.
### 8. Asks that authorise
> **Who holds the ask changed — 2026-10-08, by [ADR 0259](0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md).**
> What stands: the tiers, the proofs, the desk never authorising, and every tier completable on the away
> channel. What moved: the router, not the controller, holds authorising asks, the operator's identities
> and the factors, and judges each answer; the asker performs the option itself on a warrant. The
> controller's verbs `authorise request`, `authorise answer` and `authorisations` below are replaced there.
**An ask whose answer performs an action is held by the controller.** The router carries it like any
other ask; only the controller performs.
@@ -11,6 +11,11 @@ extends: 02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-
# 238. A commit is the build at hand: one commit, one change plan, checked off the trunk and published only on it
> **Superseded in part — 2026-10-10, by [ADR 0276](0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md).**
> Decision 2's "one commit, one change plan" no longer holds for the release after the merge: a batch of
> merges collected in a rolling merge window is planned once, as one walk with at most one commit per
> repository (issue 362). A pull request's head is still checked on its own, with its own delivery plan.
> **Superseded in part — 2026-10-06, by [ADR 0239](0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md).** Decision 6's state machine is
> built as a **delivery**'s, owned by the module `mesh-delivery` rather than by the controller. Its states are
> renamed (`releasing` → `delivering`, `done` → `delivered`) and a delivery group sits above it. Decision 7's
@@ -11,6 +11,14 @@ extends: 02-DECISIONS/0238-a-commit-is-the-build-at-hand-one-commit-one-change-p
# 239. A delivery is owned by the mesh-delivery module and runs from commit to delivered
> **Superseded in part — 2026-10-10, by [ADR 0276](0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md).**
> A delivery is no longer walked by a walk of its own trunk commit. From its merge on it is delivered by the
> walk of the batch that answers it, one walk per rolling merge window with at most one commit per
> repository, and it ends with that walk (`covered` for an earlier commit of a repository the walk carried
> later). The superseded row (published, delivering → superseded) is taken only for a walk folded before it
> started whose successor does not answer the delivery's merge; a delivery follows the walk that took its
> own over. The owner, the table, the group and its order stand as decided here (issue 362).
> **Superseded in part — 2026-10-08, by [ADR 0249](0249-a-delivery-groups-order-rules-have-a-precedence-and-a-declared-order-outranks-them-all.md).**
> Decision 4's sentence "a declared order that contradicts an inferred one is a cycle" no longer holds.
> Two rules ordering one pair both ways were always a cycle, and a group of the controller, the node-engine
@@ -53,6 +53,13 @@ Option 3.
This extends rule 5 of ADR 0216: the module removes what it placed, and also, on the person's
word, what it did not place. Rule 6 stands as written.
> **The mechanism changed — 2026-10-09, by [ADR 0273](0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md).**
> What stands: a found item leaves the home only on the person's word, with a reason, a copy kept, and a
> way back. What moved: a found item is now a condition the module raises (`agent-home-drift`), and this
> removal and the import are the operator's two answers to it; and for the operator's own home "the
> person's word" is no longer the caller's say-so (the consequence below) but one of two proofs, the
> controller's terminal or a warrant on an ask, as issue 340 requires.
## Consequences
- An item the mesh did not place can now leave a machine with its reason recorded and its content
@@ -182,6 +182,18 @@ work:
descriptions call a build request an *ask*;
- the nftables module's tool `firewall_rules`, which serves the packet filter.
> **Progressive insight — 2026-10-09.** This record deferred the code renames in four places and named no
> issue, owner or date for them: this section, §2 ("listed in §6 for a later rename and is not renamed here"),
> §3 ("Code that still carries an old name is named on an *Identifier until renamed* line") and §4's table
> ("`mesh-host` an identifier until the code rename"). Two days later the operator believed them done, and
> nothing in this record said otherwise. They are tracked by
> [issue 354](../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md),
> which holds the inventory of what carries the old names, the risk of renaming the paths of a self-updating
> daemon on live nodes, and the staged plan; each stage is owned by the repository it names, and the
> glossary's *Identifier until renamed* lines point at the issue. The decision stands. From this date a
> record that defers work names the issue that tracks it, with an owner, or the deferral is not accepted
> (playbooks 02 and 04; `cycle.py` checks it).
## Options rejected
- **Machine as the one word, node only as an identifier** — the research's proposal, and what the newest
@@ -0,0 +1,589 @@
---
topic: the mesh
status: accepted
date: 2026-10-09
deciders: jochen
reconstructed: false
supersedes-in-part:
- 0234-the-mesh-holds-a-conversation-with-its-operator.md
extends: 02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md
---
# 259. An ask is a message on the bus, any channel answers it, and the asker acts on a warrant
## Context
On 2026-10-08 the operator chose Telegram as the next channel for the operator's decisions. "Needs you:
release it, or stop it" must be answerable from the phone, with real authorisation, and without an agent
session.
What the mesh had on its main branches that day:
- **The desk performs only an acknowledgement**
([ADR 0258](0258-a-desk-click-performs-only-an-acknowledgement-and-an-answer-is-a-one-time-ticket.md)).
Release, stop, start and restart are said in words: "from the mesh MCP server; this notification cannot do
it". The messenger (the `operator-channel` seat's holder) calls `conditions silence` itself when the
operator chooses Silence on the desk.
- **The messenger holds the Telegram client in its own process**, says conditions there as words without
buttons, and keeps the bot token as its own secret `telegram-token`. Its status said on 2026-10-08 that the
token held is not a bot token: no bot has been given yet.
- **[ADR 0234](0234-the-mesh-holds-a-conversation-with-its-operator.md) §8 gives authorising asks to the
controller**: it holds the ask, keeps the operator's identities and factors, checks the proofs and
performs the action itself. None of it is built ([to-be 46](../03-DESIGN/01-to-be/46-the-conversation-with-the-operator.md)
phase 5).
- **Any node's tool runner may call any tool on any node** (ADR 0175). A tool call therefore proves
nothing about who made it: the laptop's runner carries the agents' calls. What the bus *does* prove is the
publisher of a module's events and a seat's events: only the principal granted a subject may publish it,
which is why a condition's events need no signature (design 29 §2).
The operator's directions, 2026-10-08, on the first proposal (a Telegram path through the controller):
- "I don't think we should mangle Telegram into our controller. Now we're using Telegram, but tomorrow we
could be using Signal. And today the controller needs to receive a human decision, but tomorrow a
completely different module might require human approval."
- **Two generic boundaries.** Asking a person is a mesh-wide service any module uses; the asking module
performs the action itself, with its own grants, and records who decided. Channels are pluggable: a
channel renders the question, receives the answer and verifies the sender by its own means. Nothing
outside the channel module knows which service it talks to.
- **The controller only asks and acts.**
- **The flow is native to the bus**: a request is a message, pending requests survive a restart, each
answer is recorded once, the asker reads its request's state from a bucket, and the bus's permissions
decide who may publish what. Events carry no secret; request and reply may. No HTTP between modules.
The operator approved this record on 2026-10-08, with the word *warrant*, and decided two points it had
left open:
- **The `telegram` module runs on the control node under a service account of its own, not the
operator's.** Claude Code runs as the operator on every machine; the service account keeps an agent away
from the bot token and the channel's state.
- **A newly linked identity waits one hour before it can approve**; acknowledge works at once. Every link
and unlink is announced on every channel. This holds until a code from an authenticator app exists.
The operator accepted this record as revised on 2026-10-09 — every part marked "added" or "revised" on that day,
after the security reviews and the confirmation review — and asked that the mesh be able to ask them a question,
and ask for an approval, from then on.
### Against GENESIS
- **Core value — sovereignty.** Telegram stays an outside dependency taken deliberately (ADR 0234), and
is now one module that can be removed without touching anything that asks.
- **Core value — failure must be loud.** An ask that no channel can carry is refused in words to its asker
at once, never queued in silence. An answer that is refused is said on the channel it came from.
- **Effect — "when something genuinely needs a decision, you are asked, with the context".** Any module
can now ask, not only the controller.
## Considered Options
### Where an ask is held
1. **The controller holds authorising asks** (ADR 0234 §8). Rejected by the operator: the next module
that needs a person (a backup module, the delivery owner) would ask through the controller, and the
controller would perform actions in other domains.
2. **A new seat for decisions, beside `operator-channel`.** Rejected: an ask needs the router's routing,
the work context and the content rule; a second seat would ask the router for each of them, and two
holders would keep two lists of who the operator is.
3. **The `operator-channel` seat's holder, the router, holds every ask, authorising or not, and judges
the answers.** Chosen. ADR 0234 already gives it ordinary asks; this adds the authorising ones.
### Who performs the action
1. **The decision holder calls the asker's verb.** Rejected: the router would act in every domain with a
grant to every verb, which is what ADR 0258 took away from the desk.
2. **The asker performs on hearing "yes" from anyone.** Rejected in ADR 0234: a relayed "yes" is not an
answer.
3. **The asker performs on a warrant**: the router's record that the operator chose an option, published
on a subject only the router may publish, for that asker alone. Chosen. The bus proves the warrant's
source as it proves a condition's. A signature was weighed and rejected: it needs a key in every asker
and proves nothing the server's permissions do not.
### How an ask, a warrant and an answer travel
1. **Tool calls** (an `ask` verb, an `answer` verb). Rejected: any tool runner may call any tool, so the
router could not tell the controller's ask from an agent's, nor a channel's answer from anybody's.
2. **HTTP between modules.** Rejected by the operator's direction, and it would carry its own
authentication beside the bus's.
3. **Subjects whose last token names the publisher**, granted to that publisher alone, on streams that
survive a restart. Chosen. The mesh already grants a machine its own token on a node seat's events
(ADR 0219); this is the same rule for a caller's module and a channel's kind.
### Who is the operator on a channel
1. **A setting naming the operator's account on the service.** Rejected: any principal granted
`mesh-controller.settings` may write a setting, agents included, so the operator's identity would be
anyone's to set.
2. **The channel module's own allow-list.** Rejected in ADR 0234: the channel would decide who the
operator is.
3. **The router's list of the operator's identities, per channel kind, written only by linking.**
Chosen. A channel module passes the sender as the service authenticated it, as an opaque string; the
router alone compares it with its list.
### How a channel takes its answers (Telegram)
1. **A webhook.** The control node is the stably reachable node, so it could take one. Rejected all the
same: it needs a public route, a name and a certificate into the control node, guarded only by a
secret header; a stolen token could point it elsewhere.
2. **Long polling**, as ADR 0234 chose. Chosen: outbound only, unaffected by the control node's address,
answered as fast as a webhook, and a stolen token can read updates (visible as a conflict) but not
inject one.
### Where the desk's buttons live
1. **Inside the router**, as its desktop adapter (to-be 46 §11). Rejected: the router would know one
channel, and the operator asked that the desk be just another channel.
2. **The dunst module** claims the channel. Rejected: dunst holds a node seat on each machine, and a
channel kind has one holder for the mesh.
3. **A small module of its own, kind `desktop`**, sending through each machine's `node-notifier` and
reading dunst's `action-chosen`. Chosen.
## Decision
### 1. The words
- An **ask** keeps its glossary sense; an **authorising ask** is one whose chosen option the asker
performs. Each option carries its **assurance level**: `acknowledge`, `approve` or `destroy` (ADR 0234's
tiers, the glossary's word).
- A **warrant** (new) is 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. It is the only thing an asker acts on.
- **The router** keeps its sense (the `operator-channel` seat's holder). It now holds every ask and
issues warrants. It never performs an option.
### 2. The boundaries
| Party | Does | Never |
|---|---|---|
| **an asker** (any module; first the controller) | publishes an ask under its own name; reads its asks' state; acts on its own warrants with its own grants; records the warrant in its own record of the act | performs without a warrant; reads another asker's asks |
| **the router** (`operator-channel` holder, the `messenger` module) | keeps asks, tokens and the operator's identities; routes an ask to the channels whose capabilities meet each option's level; judges each answer; issues one warrant per ask | calls an asker's verb; knows a service's API |
| **a channel** (a module claiming a kind on `channel` and `intake`: `telegram`, `desktop`) | shows what the router sends, with the router's tokens on its buttons; passes back the token chosen and the sender as its service authenticated it | judges whether the sender is the operator; holds another channel's secret |
### 3. The subjects, streams and buckets
Every name below follows the mesh's existing shapes (`mesh.seat.<seat>.<accept|event|tool>.…`, a module's
state bucket, the EVENTS stream). `<asker>` is the asking module's name; `<kind>` a channel's kind.
| What | Subject or bucket | Kept in | Publisher (granted) | Reader (granted) |
|---|---|---|---|---|
| an ask | `mesh.seat.operator-channel.accept.ask.<asker>` | the seat's work queue `SEAT_OPERATOR_CHANNEL` (kept 7 days, removed when the router has recorded it) | the asker, its own name only | the router |
| an ask cancelled by its asker | `mesh.seat.operator-channel.accept.cancel.<asker>` | the same | the asker, its own name only | the router |
| the asks and their state | the router's bucket `asks`, key `<asker>.<id>` | its own state, closed records kept 30 days | the router | the router; each asker reads its own prefix only (direct get) |
| answer tickets | in each ask's record in `asks`: one per option and channel kind, used once, dead when the ask closes | its own state | the router | the router |
| the operator's identities | the router's bucket `identities`, key `<kind>.<identity>` | its own state | the router | the router |
| a warrant, or an ask's end without one | `mesh.seat.operator-channel.event.decided.<asker>`, message id `<asker>.<id>` | EVENTS (7 days), deduplicated by the message id | the router only | the asker, its own name only, through its durable consumer |
| show, edit or say on a channel | `mesh.seat.channel.accept.<show\|edit\|send>.<kind>` | the seat's work queue `SEAT_CHANNEL`, one worker per kind | the router | the holder of that kind only |
| an answer chosen, a link asked, a message that failed, the channel's standing | `mesh.seat.intake.event.<choice\|link\|failed\|standing>.<kind>` | EVENTS | the holder of that kind only | the router |
| a code typed (a link code; later a TOTP code) | `mesh.seat.intake.proof.code.<kind>`, core request and reply | nothing: never on a stream | the holder of that kind only | the router, which answers |
**The grants, composed by the controller as it composes every bus account:**
- **A caller-named verb.** A seat may mark an accepted verb or an emitted event as named by its caller
(`ask.*`, `cancel.*`, `decided.*`). A module that uses the seat is granted to publish the accept, and to
subscribe the event, with its own module name as the last token, and no other. A tool runner carrying
modules is granted the same for each module it carries. The controller asks as `mesh-controller`.
- **A kinded bench** (ADR 0234 §2, built here for `channel` and `intake`). A claim carries `kind`; two
claims of one kind are refused. A holder subscribes its own kind's accepts, publishes its own kind's
events and proofs, and nothing of another kind. Each kind has its own worker on the seat's queue, so a
channel that is offline keeps its work and holds up no other.
- **A seat's `proof` verbs** are core request and reply on `mesh.seat.<seat>.proof.<verb>.<kind>`: no
stream covers them, so a code is never persisted (the mesh's rule: events carry no secret). A holder
asks with its own kind; the modules that watch the seat answer.
- **The seat declares all of it**: `kinded`, the verbs named `by-caller`, its `proofs`, and the holder's
`records` its users read under their own name. Only `channel` and `intake` may be kinded.
- **A claim of a kinded bench carries `kind` and `capabilities`** (`channel-capabilities/1`, ADR 0234 §2).
The controller names every kind, its holder and its capabilities in the membership of each module that
uses or watches the bench: that is where the router reads them, never from a channel.
- **A record readable by its asker.** The router's `asks` bucket is read by an asker under its own prefix
only: direct get on `$KV.<bucket>.<asker>.>`.
- **No `answer` tool.** A tool runner may call any tool, so an answer that arrived as a tool call could
come from anyone. An answer arrives only as an intake event of a kind.
### 4. An ask
An ask carries: an `id` the asker chose, unique to it; the **question** in plain words, held to the plain
rule and the content rule (ADR 0253, ADR 0234 §6); its **options**, at most four, each a label, what it
does in plain words, its assurance level and an option id; **who may answer** (the role `operator`, the
only role today); an **expiry**; **what happens on expiry**, in words the operator is shown ("the delivery
stays held"); and optionally a subject it is about (a condition's key), so a newer ask about the same
thing replaces the older.
- **Refused at once, in words, on the decided subject**: an ask whose words break a rule, one none of whose
options any ready channel can carry, or a fourth open ask from one asker. An option no channel can carry
now (`destroy` until a TOTP factor exists; `approve` while no account is linked) is not offered, and the
words say where it can still be given.
- **An authorising ask never defaults.** At its expiry the router ends it with `expired`; the asker does
what it said it would.
- **An approve option expires within 24 hours; a destroy option within 10 minutes of being shown**
(ADR 0234).
### 5. The router judges an answer
On an intake `choice` of kind K carrying token T and sender S, the router refuses at the first failure and
says the refusal on K's channel:
1. T is known, unused, unexpired, and was issued for kind K;
2. the ask is open, and the option T names belongs to it;
3. S is on the router's list of the operator's identities for K;
4. K's declared capabilities, **read from the controller's record of its claim and never from the
channel**, meet the option's level: `acknowledge` needs `choice` and `exact-render`; `approve` needs
`verified-sender` as well (P1); `destroy` needs two proofs, one of them a code (P2); and for `approve`
and above, **the controller's `root-free` verb answers, at that moment, that no agent can become root
without a person** on the router's machine and on K's channel's machine (§8 rule 2, added on 2026-10-09:
an absent condition is no pass);
5. the ask is closed with a compare-and-set on its record, so a second answer loses.
Then it publishes the warrant on `decided.<asker>`, and sends every channel that showed the ask an edit
naming the outcome and removing its buttons.
**The desk declares no `verified-sender`**, so it answers only `acknowledge` options, as ADR 0258 decided.
Telegram declares it while placed where no agent runs as the operator, so it answers `approve`.
### 6. The warrant, and the asker's act
The warrant carries the ask's id and asker, the option, `by` (the operator, as a kind's identity: "the
operator, via telegram, user id verified"), the channel module, the proofs present, the assurance level
met, and when. It carries no secret.
- **The asker acts once per ask id**, keeping that in its own record, and only on a warrant from its own
`decided` subject. A warrant that says no time it was given, or for an ask that says no expiry, is refused:
neither can be shown to be in time (the confirmation review of 2026-10-09).
- **A silence never takes an approval back** (the confirmation review of 2026-10-09, M1). The controller
asks a condition's acknowledgements (Silence) apart from its approvals (Release, Stop, Restart), and choosing
Silence silences the condition; the first build then took the approval ask back as no longer wanted, so an
acknowledgement, which a desk click may give, ended an approval. An approval asked about a condition that is
silenced since stays open, unchanged, until it is answered on a channel that proves who answered, or expires.
- **A warrant authorises one act, as the person was shown it** (added on 2026-10-09, before the code was
merged). Every option above `acknowledge` carries the digest of exactly what the asker performs when it is
chosen — the verb, the machine and every argument (`binds`); an ask without it is refused at the router.
The router puts the digest of the ask as it took it in the warrant. The asker acts only when that digest
is the one of the ask it keeps, and only when the act it is about to perform is the one the option bound:
an ask published again under the same id, or a record of the act changed after the ask, is refused and
nothing is done. **Both digests are over named fields in a canonical encoding** (each value written as its
length, a colon and its bytes, in a fixed order, versioned), never over a language's encoding of a
structure, so any language reproduces them and a field added to an ask changes no digest until it is
added to the encoding on purpose (the review of 2026-10-09).
- **The router publishes each warrant under a message id nobody can guess** (a random one per publish), on
the asker's `decided` subject, which only the router may publish; the asker deduplicates on its own record
(a compare-and-set claim), never on the bus's duplicate window. A guessed id published first by another
principal into the same stream would otherwise have the real warrant dropped as a duplicate (the review of
2026-10-09).
- **Its record of the act names the warrant**: a hand-act's `by`, `via`, `ask` and `proofs` (ADR 0234 §8),
a delivery's transition, a silence's why. The hand-act log counts it as a person's decision, not a repair.
- **The controller only asks and acts.** For a condition that needs the operator it asks with the
condition's actions as options, and on the warrant it calls the verb it named, with its own grant. It
holds no channel, no identity and no factor.
- **A warrant is not yet the only way to these acts.** The acts a warrant authorises today —
`mesh-delivery.release` and `stop`, the controller's `plans` go and stop, `node-service-manager.restart`,
and `conditions silence` — are still verbs an agent may call directly with its own grant. Until
[research 039](../01-RESEARCH/039-a-tool-that-needs-a-persons-approval/00-overview.md) (a tool that needs a
person's approval) withholds them from every caller without a warrant, a warrant proves that the operator
chose an act, not that only the operator could have caused it. Said here so no reader takes the warrant for
that second guarantee.
### 7. Who the operator is on a channel: linking
- **An answer from an account that is not the operator's** is refused, and the operator is told on the linked
account which account tried (by name and number); the other account is told nothing (added on 2026-10-09:
a question forwarded, or a phone in other hands, is something the operator must hear of).
- **The router's list is the only list.** It is changed only by linking and unlinking, never by a setting.
- **Linking:** the operator sends the bot `/start`. The channel publishes `link.<kind>` with the sender
and the name the service shows. The router shows a six-digit code on the desk, with that name, valid
for ten minutes. The operator sends the code to the bot; the channel passes it on the `proof` subject;
the router links the identity when it matches, and says so on every channel.
- **A code is shown to a person or not at all** (added on 2026-10-09, after the review). The router issues a
code only while the desk channel says it is ready, and tells the sender the desk's reason when it is not;
a code is never queued for later. The desk is ready only while a named desk machine's notifier said within
five minutes that it is not paused (the notifier's `paused` event), and the code goes only to such a
machine; shown nowhere, it is said back to the router as failed. Every message carrying a code is marked secret: no
channel and no router record keeps a copy, the desk channel stores none and hands it to the node's
notifier as transient, and the notifier keeps it out of its history, where an agent could read it later.
- **The desk channel runs as an account of its own**, like the router and Telegram (§8 rule 1): the codes
pass through it. Registration holds every holder of a `private` kind to it, as it holds a `verified-sender`
holder (the confirmation review of 2026-10-09).
- **One identity per kind.** A second link of a kind is refused until the first is unlinked.
- **Unlinking is an approve ask, answered on the account that is linked** (revised after the security
review of 2026-10-08). It is never a plain tool: whoever unlinks the operator's account could link one of
their own in its place. The notice of an unlink, and of any link for an hour after it, is sent to the
account being replaced by name, before anything changes, and to every other channel.
- **A link within a day of an unlink waits for the replaced account's approval**, asked there. When that
account is lost with its phone, the operator acts as root at the router's machine (break-glass, as ADR 0234
§8 does for a lost factor): a regular file `unlink.<kind>` or `allow-link.<kind>`, owned by root, in the
router's own state directory (mode 0700, owned by the router's account). The router acts on it within a
minute, deletes it, ends every open ask of its own, and announces on every channel what root did. A file of
any other owner, or not a regular file, is refused and said: an agent without root cannot make one.
- **Linking is bounded**: at most three links asked per account an hour and ten per kind, counted in the
router's own state so a restart forgets nothing (a count that cannot be read or written refuses the link); the code's notice on the desk names the account by
the name and the number the service gives, and only a channel declaring `private` (the desk) may show a
code. **A residual risk of the bound, accepted** (the confirmation review of 2026-10-09): anyone may make
throwaway accounts and send the bot `/start` ten times in an hour, so the operator's own link is refused
for the rest of that hour. It denies a link, never makes one, and every refused link is said; the operator
links when the hour has passed. A bound per sender alone would let the same throwaway accounts flood the
desk with codes, which is worse.
- **The ask to unlink or relink binds the account**, not only the kind (added on 2026-10-09): a warrant for
unlinking one account never unlinks the one linked after it. A link re-checks that its kind has no account
when it is made, and an account just unlinked cannot link again at once from a code shown before.
- **An account still in its first hour can be unlinked without its own approval** (added on 2026-10-09, after
the review): it cannot approve yet, so its consent protects nothing. When another account sends `/start`
while one is in its first hour, the router asks at the desk, as an acknowledgement bound to that account,
whether to unlink it, and tells the sender so. If the hour has passed when the answer arrives, the unlink
is refused. After the hour, unlinking needs the linked account's approval, or break-glass.
- **An unlink at the desk in the first hour bars nothing** (the confirmation review of 2026-10-09, finding
N1-router). A desk click is anyone's at the desk machine — a program there can forge one — and the first
build barred the unlinked account for a day and made the next link wait for it. Another account's `/start`
offered the desk the unlink, a forged click took it, and the operator's own account stayed barred, again
and again, so the operator could never approve. Now such an unlink lets any account, the operator's too,
link again at once with a code shown on the desk. **The day's bar is set only by an unlink the account
itself approved, or root's**; root's `allow-link` at the router's machine lifts any bar. What is said on
every channel names which it was.
- **A new link answers `acknowledge` at once and `approve` only after one hour.** The hour runs from the
moment the link is made, is kept in the identity's record, and is said in the announcement ("it can
approve from 15:40"). An approve answer inside the hour is refused and said on the channel. Decided by
the operator on 2026-10-08, until an authenticator-app code exists.
- **Once a TOTP factor exists**, linking and unlinking are destroy asks (ADR 0234 §8).
- **A residual risk is accepted until then:** on the X11 desk an agent running as the operator can read the
code and send it from an account of its own. It is said loudly on every channel when it happens; during
the hour the account cannot approve, and the operator can unlink it from the desk without its approval (the
rule above — corrected on 2026-10-09: before that rule, the hour gave no way to unlink an account that
would not consent); and the factor closes it.
### 8. Who may say what on the bus: the trusted parties' own accounts
*Revised after the security review of 2026-10-08; the revision accepted by the operator the same day.* The first version of
this section rested on the tool runner running as root with a credential only root reads. **On the control
node it runs as the operator's account, its bus credential belongs to that account, and that account may
become root without a password.** Every agent runs as that account. So an agent there could read the runner's
credential and publish anything any carried module may: a warrant, an answer from Telegram, a link. The
service account of the first version kept the bot token from nobody.
The revision rests on three rules.
**1. The trusted parties hold bus accounts of their own, never a runner's.**
- The **router** (`operator-channel`'s holder) and **every channel that declares `verified-sender`** run as
their own process, as their own service account (`messenger`, `telegram`), with a bus account of their
own: an own secret `broker`, owned by that account, mode 0600. The manifest says so with `runs-as`; the
controller composes for such a module a runtime of its own — the machine's runtime program, run as that
account, serving that module alone on that credential — and the machine's runtime never carries it.
- **The controller refuses to compose** into any runner's user, or into any principal whose process runs as
the node's operator account:
- publishing on a by-caller event of `operator-channel` (a warrant);
- writing the router's state (asks, identities);
- publishing on, or answering, a kinded bench's subjects for a kind declaring `verified-sender`.
A manifest asking for it is refused at registration, and a runner carrying such a module is refused at
composition, naming the module.
- **Proofs are answered by the holder of `operator-channel` alone**, not by any module watching the bench;
and **a kind's accepts are published by that holder alone**, not by every user of the bench.
- **The tool runner hands a bundle no bus variable** of its own: the credential and the bus's address are
taken out of every bundle's environment.
**2. `verified-sender` is honoured only where the controller can show who holds it.**
The controller passes a kind's `verified-sender` to the router only when its holder runs as its own
account, with its own bus account, on a machine where **no account an agent runs as can become root without
a person**. Otherwise it strips the capability from the membership, and the router answers that kind only
as far as acknowledgements, saying why. The same check holds the router: while it fails on the router's
machine, the router issues no approve warrant, and the condition says what to change.
*Revised on 2026-10-09, after the review of the first build.* **Only a positive, recent measure is a pass;
the absence of a condition never is.** The first build let the router approve while no `agent-root`
condition was open — so before the first self-check, after a probe error, with the conditions view lost, or
with the router not knowing its own machine, it approved. Now:
- **The controller's `root-free` verb** answers, for each machine asked, whether no agent can become root
there without a person, judged at the moment it is asked. It is a pass only when the machine names an
account its agents run as, the machine's node-engine judged that account unable to become root (ADR 0266:
the node-engine reads it as root, on every look, and the controller takes a verdict at most fifteen
minutes old), and the login shell's `execute` is not served there (ADR 0268). Anything else — no agent
account named, so agents run as the operator; a verdict missing, stale or unhealthy; a store or the bus not
answering — is not a pass, and says why.
- **The measure is the node-engine's, not the measured account's.** The first build asked the sudo module,
served by the machine's runtime — whose account is the one an agent's calls run as — so the account
being measured answered its own measure. The node-engine runs as root, which an agent that cannot become
root cannot be. The machine's runtime account is counted as able to become root, always: the mesh's
acting tools give it passwordless sudo.
- **The router asks `root-free`** for its own machine and the answering channel's, when it shows an
approve option and again when an approve answer arrives; an error, a missing machine, or a router that does
not know its own machine is not a pass. **The controller asks it too when it composes**, and strips
`verified-sender` from a kind without a pass; the router's live question covers a pass that ends between
compositions. The `root-not-free` condition is raised on the same judgement, so the condition and the
verb never disagree.
- *Added after the confirmation review of 2026-10-09.* **Only the serving controller answers `root-free`.**
Composed from the controller's own manifest, the module principal of the machine running the controller
and that machine's runtime were granted the controller seat's verbs too, and that runtime's credential is
one an agent there may hold. The controller's own seat is now served by the controller's principal alone,
in grants and in memberships, and a grant test composes every principal and finds exactly one that may
answer `root-free`. **A machine waiting for its node-engine's first setuid search is not root-free**
(ADR 0266 keeps the self-check quiet for that bound after a restart; `root-not-free` keeps the same quiet,
and the verb still answers no). The condition has its own key, apart from ADR 0266's
`agent-can-become-root`, which a second probe wrote under the same key with other words. **The router asks
`root-free` before it takes its work**, never while holding it — a controller slow to answer held every
ask, answer and link behind it — and judges an answer with the word it was given then; inside the work it
reads only a word under half a minute old, and an ask that failed drops the last word.
**3. What remains, said plainly.**
- **Root on the router's or the channel's machine defeats all of this.** The self-check raises an urgent
condition while an agent can become root there without a person; on the control node today it can (the
operator's account has passwordless sudo, measured on 2026-10-08). **The operator chose, on 2026-10-08,
that agents on the control node run under an account of their own without sudo.**
- **An agent reaches root through the mesh's own tools too.** Any machine's tool runner may call any tool
on any machine, and the login shell seat's `execute` runs a command as the account the runner runs as —
on the control node, the operator's account, with passwordless sudo, which the mesh's acting tools rely
on (measured on 2026-10-08). So an agent on any machine can run a root command on the control node
without touching its own account. The self-check therefore counts both ways in: an account an agent
runs as that can become root without a person, **and** a tool an agent may call that runs a command
given to it as an account that can. It fails until both are closed; the second is decided separately
(the login shell's reach, and what an agent's calls are granted).
- **Since 2026-10-09 the second way is closed on the control node**:
[ADR 0268](0268-the-login-shells-execute-is-withheld-on-the-control-node-until-a-call-to-it-needs-a-persons-approval.md)
withholds `execute` there through its holder's setting. The self-check counts the login shell only where
`execute` is served, failing closed: served while the holder's setting for that machine is `serve`, or
while the bus hears the verb answered there, or while the bus cannot be asked. The first way closes with
ADR 0266 (hq pull request 225, not yet merged): agents on the control node run under an account of their
own, without sudo.
- **What cannot be measured is not a pass** (added on 2026-10-09). A machine where a trusted party runs and
the node-engine's verdict on its agent account is missing, stale or not given raises the same urgent
condition, saying it was not measured, and `root-free` answers no for it.
- **A known gap, accepted by the operator on 2026-10-09: the operator's own Telegram client on a machine
where agents run as the operator** (issue 344, hq pull request 244, not yet merged). The operator's Telegram may also run on such a
machine — their laptop, with Telegram Desktop. There an agent can read the client's data (found readable by
everyone) and drive the X display, and tap Approve as the operator. The router cannot tell such a tap from
one on the phone. Closing it needs agents under an account of their own on that machine, without access to
the operator's X display, and the client's data readable by the operator alone; none of it is built or
measured now, and approvals are not held back for it. Until then, approve from the phone.
- **A known gap, accepted on 2026-10-09: the controller's credential can rewrite the router's state**
(issue 344, hq pull request 244, not yet merged). The controller composes every account and grant on the bus, so a grant withheld from
its own credential — no write to the router's identities or asks — would be withheld by the very principal
that writes the grants, and undone by its next composition. Moving the router's buckets where the
controller cannot write therefore protects nothing against a controller that is not trusted, and is not
done; the controller is trusted with every record, and root on the control node is the mesh.
- **The controller holds the whole JetStream API** today. Narrowing it to the streams and buckets it owns is
planned work of its own; until then the controller is trusted with every record.
- **On the X11 desk an agent can read a code as it is shown or typed** (ADR 0234's accepted risk). The hour
after a link, the replaced account's approval and the announcements bound what a stolen code can do.
### 9. What is superseded in ADR 0234
- **§8 "an ask whose answer performs an action is held by the controller", the controller's verbs
`authorise request`, `authorise answer` and `authorisations`, and the controller performing:** the
router holds the ask and issues a warrant; the asker performs.
- **§3 "the controller's list of the operator's identities"** and §8 "the factors' secrets are the
controller's own": the identities and, when built, the TOTP factor and its recovery codes are the
router's own state, with ADR 0234's rules on showing them once, at a terminal, never on a channel.
- Everything else in ADR 0234 stands: the tiers and proofs, the kinded benches, the content rule, the
desk never authorising, long polling, every tier completable on the away channel.
### 10. The first channels and the order of work
1. **The controller:** the kinded bench with `kind` on a claim; the `channel` and `intake` seats; the
caller-named and per-kind grants; `proof` subjects; the asker's read of its own prefix.
2. **The tool runner (mesh-tools)** carries a bundle's seat traffic: publishing an accept or an event
with its own name or kind, taking a held seat's work, asking and answering a proof, a compare-and-set
on its state, and serving one module alone when it runs on that module's own account.
3. **The shared library (mesh-sdk):** an asker's client (ask, cancel, read its state, take its warrants)
and a channel's (take show, edit and send for a kind; publish a choice or a link; send a proof).
4. **The messenger** becomes the router of this record: asks, tokens, identities, judging, warrants,
expiry, linking. Its Telegram client and its desk adapter leave it.
5. **`telegram`**, a new module claiming kind `telegram` on both benches: long polling, buttons carrying
only a token, `/start` linking, the client's defects of to-be 46 §12 fixed in the move (D1, D4 to D8,
D10; D2 and D3 are the router's, D9 the watcher's), its token as its own secret. **`desk-channel`**, a new module claiming kind `desktop`.
6. **The controller as an asker**: conditions that need the operator are asked; it acts on warrants and
records them; the words "from the mesh MCP server" give way to the router's own line, below.
7. **The vault** gains a verb by which the operator gives a module's own secret through a hidden prompt
on the desk, so a bot's token never passes through an agent's session. *As built (2026-10-09):* the
controller's `give`, through its `secret accept` path. It refuses `broker` and any secret the mesh makes
itself; the prompt is written by the desk's launcher from names alone (module, secret, machine), never
from words a caller chose, and says the controller asks; the bus lets only the controller publish the
prompt's verb; no secret value is accepted through a verb otherwise; and every value given raises the
urgent condition `secret-given` on every channel until the operator silences it.
**A trusted party's secret is never given at a desk** (the confirmation review of 2026-10-09, finding
N1-give). The desk prompt's answer comes back over the desk machine's bus, on the subject that machine's
runtime answers, and on a desk machine agents run as the operator, who holds that runtime's credential:
an agent called `give` for the bot token and answered the prompt first with a token of its own, sealed to
the call's key, and the channel the operator's answers are proven on became the agent's. Now the secret of
a module running as an account of its own (the router, Telegram, the desk channel) is refused at the desk
before anybody is asked, naming the line to type at the controller's terminal instead —
`nox secret accept <node> telegram telegram-token` (`nox` is `mesh-cli`'s alias; on the control-node,
from the operator's own account, it is the controller's terminal, ADR 0272). There it is announced
on every channel, the one being replaced among them (it still runs on its old token until the next push),
**before** it is kept, and not kept when that announcement fails. A grant test holds that only the desk
machine's launcher, carried by that machine's runtime, may answer its prompt; for any other module's
secret that remains the accepted risk of a desk where agents run as the operator.
**Where an ask can be answered is said by the router**, from the channels that qualify: "Answer on
Telegram, or from the mesh MCP server." The asker's words never name a channel.
## Consequences
- **A channel is a module and nothing else changes when one is added.** Signal or mail is a module
claiming a kind and declaring capabilities.
- **Any module may ask a person**, through one seat, and acts on the answer with its own grants. ADR
0258's desk Silence becomes an ask of the controller's, and the messenger loses its grant to
`conditions silence`.
- **The controller loses three verbs it never built** and gains an asker's client.
- **The router's state grows**: asks, tokens and identities, each a bucket of its own.
- **Harder:** an asker must keep which warrants it acted on; the shared library carries it.
- **Harder:** the controller composes two new grant rules, and an asking module must use the seat in its
manifest to be granted anything.
- **The bot token moves** from the messenger's own secret to the `telegram` module's, given at the desk
(`give`, §10 step 7). **Nothing the channel or the router trusts is a setting**
([issue 342](../04-ISSUES/342-a-password-is-taken-as-a-setting-and-outranks-the-vaults/00-report.md): a
password, token or key is not a setting). The operator's Telegram account is the router's identity list,
written by linking alone; no chat id is configured anywhere. The two settings these modules keep — the
router's time zone and the desk channel's machines — are the terminal's, as every mergeable file's are
since [issue 340](../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md).
- **Accepted risk:** a linked Telegram account, if taken, can approve (announced, reversible), never
destroy. Linking before a TOTP factor exists carries the X11 risk in §7.
## How it is checked
| Rule | Checked by |
|---|---|
| An asker publishes asks only under its own name, and hears only its own warrants | controller grant tests: a module using `operator-channel` is granted `accept.ask.<itself>` and `event.decided.<itself>`, no wildcard; the lab's proof `asks` publishing under another name on a real bus is refused by the server |
| Only the router publishes a warrant | controller grant test: `event.decided.*` is in no principal's publish list but the holder's |
| A channel publishes only its own kind's answers, links and proofs | controller grant tests over a kinded claim; a second claim of a kind is refused |
| A code is never on a stream | controller test: no stream's subjects cover `mesh.seat.*.proof.>` |
| A token is used once, expires, and is bound to its ask, option and kind | router tests: the same token twice; a token past its expiry; a token from another kind; a token of a closed ask |
| An answer from an identity not linked is refused and said | router test |
| A level a channel's declared capabilities do not meet is refused | router tests: an approve option answered from `desktop`; capabilities taken from the controller's record, a channel's own claim ignored |
| A second answer to one ask loses | router test: two answers raced; one warrant |
| An authorising ask never defaults | router test: expiry publishes `expired`, never an option |
| An ask no ready channel can carry is refused at once, in words | router test |
| A condition that needs the operator and could not be asked (no router, or the ask refused) is a condition of its own, `asks-undelivered` | controller test (`TestAnAskThatCannotBeDeliveredIsSaid`) |
| Linking needs the code shown on the desk, and is said on every channel | router tests: a wrong code, a late code, a second link of a kind |
| A new link approves only after one hour; it acknowledges at once | router tests: an approve answer at 59 minutes refused and said, at 60 accepted; an acknowledge at once accepted |
| No runner's user, and no principal running as the operator's account, may publish a warrant, write the router's state, or speak for a verified-sender kind | controller composition test over every such grant; registration refuses a manifest asking for it |
| `verified-sender` reaches the router only from a holder with its own account on a machine no agent can become root on | controller test stripping the capability; self-check probe raising the condition |
| Unlinking and a relink within a day are approve asks answered on the linked account; the notice reaches it first | router tests, with a fake channel that honours the addressee |
| No bus variable reaches a bundle | tool runner test over a bundle's environment |
| A module of its own account is served by a runtime of its own, as that account, on its own bus credential, and never by the machine's | controller composition test (`runs-as`); registration refuses root, an account the module does not make, secrets it does not own, no broker; the tool runner test that such a runtime serves that module alone |
| While an agent can become root without a person where the router or a verified channel runs, it is said and nothing proven there approves | controller probe test (fails with agents as the operator's account, and with the login shell running as an account with passwordless sudo; passes once neither holds); the sudo module's `sudo_escalation` test; router test refusing approval |
| A tool call never publishes an intake event or a proof | tool runner test: only a bundle's own channel reaches the seat publish, for its own kind |
| The asker acts once per warrant and records the warrant | shared library test; controller test reading the hand-act after a warrant |
| A warrant authorises only the act its option bound, for the ask as it was shown | shared library tests (`TestAWarrantAuthorisesOnlyTheActItsOptionBound`); controller test (`TestAWarrantPerformsOnlyTheActItsOptionBound`); router test (the warrant names the ask's digest) |
| Only the serving controller answers `root-free`; a machine waiting for its first setuid search is not root-free | controller grant test `TestOnlyTheServingControllerMayAnswerRootFree` (one answerer); `TestAMachineWaitingForItsFirstSetuidSearchIsNotRootFree`; `TestRootNotFreeIsQuietWhileTheFirstSearchRunsAndKeyedApartFromDA` |
| A desk unlink in the first hour bars nothing; root lifts any bar (N1-router) | router tests `TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing`, `TestBreakGlassLiftsABarOnRelinking` |
| A trusted party's secret is never given at a desk; only the desk machine's launcher answers its prompt (N1-give) | controller tests `TestATrustedPartysSecretIsNeverTakenAtADesk`, `TestOnlyTheDeskMachinesLauncherMayAnswerItsPrompt` |
| At the controller's terminal, a trusted party's secret is announced on every channel before it is kept, and not kept when the announcement fails; a machine the mesh does not know is refused before anybody types (N1-give) | controller tests `TestATrustedPartysSecretGivenAtTheTerminalIsAnnouncedBeforeItIsKept` (fails when the announcement before keeping is removed), `TestAGiveNamingAMachineTheMeshDoesNotKnowAsksNobody` |
| A silence never takes an approval back (M1) | controller test `TestAnAcknowledgementNeverSharesAnAskWithAnApproval`, with the condition silenced as the controller silences it |
| The router never waits on the controller while holding its work | router test `TestTheRootFreeWordIsNotAskedWhileTheWorkIsHeld` |
| The self-check counts the login shell only where `execute` is served, and what it cannot measure fails | controller probe tests (`TestTheLoginShellCountsOnlyWhereExecuteIsServed`, the unmeasured case) |
| The whole flow, before anyone uses it | the lab's proof `asks` (mesh-lab): a real bus with the controller's composed grants, the router and the Telegram channel in runtimes of their own accounts, a fake Telegram API — approve acted once, decline, expiry, a replayed tap, a tap from another account, a forged warrant, an ask under another name, an ask from a module that may not ask |
| The controller holds no channel code | review; the controller's module imports no channel package |
| End to end | live acceptance after rollout (mesh-lab `asks/live-acceptance.sh`): a **rehearsal** (`rehearse`), started at the controller's terminal alone, approved on the phone, acted on once and performing nothing; the hand-act reads `warrant rehearsal rehearsal=approve`, by "the operator, as telegram identity …", via "telegram (telegram), user id verified", proofs P1 |
| An answer from another account is refused and the operator is told which account tried | router test (`TestEveryAnswerThatIsNotTheOperatorsIsRefusedAndSaid`); the lab's proof |
| A message carrying an ask's answers is never folded into a digest | router test (`TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest`); the lab's proof, where it was found |
## References
- [ADR 0234](0234-the-mesh-holds-a-conversation-with-its-operator.md) (the conversation, the tiers and
proofs; §3 and §8 in part superseded here)
- [ADR 0258](0258-a-desk-click-performs-only-an-acknowledgement-and-an-answer-is-a-one-time-ticket.md)
(the desk performs only an acknowledgement; one-time answer tokens)
- [ADR 0253](0253-a-condition-says-itself-to-the-operator-in-plain-words-beside-its-summary.md) (a
condition's actions and plain words)
- [ADR 0246](0246-a-seats-new-verb-is-promised-before-it-is-required.md) (a new seat's verbs are required
from the start)
- [ADR 0255](0255-the-bar-takes-blocks-as-data-from-the-module-that-knows-the-hardware-placed-where-the-machine-has-it.md)
(a generic boundary in front of pluggable modules)
- [To-be 46](../03-DESIGN/01-to-be/46-the-conversation-with-the-operator.md) §3, §4, §10, §11 and §13
- [Issue 320](../04-ISSUES/320-the-operator-could-not-read-the-meshs-notifications/00-report.md),
[issue 324](../04-ISSUES/324-a-desk-click-could-answer-in-the-operators-name/00-report.md)
@@ -120,10 +120,15 @@ The controller resolves three machine facts from it:
The tool runner is given `MESH_AGENT_ACCOUNT` and `MESH_AGENT_HOME` beside the operator's.
**On the control node the agent account is `agent`.** The other nodes keep their agents as the operator for
**On the control node the agent account is `nox`.** The other nodes keep their agents as the operator for
now. The operator's machines do so because the agent works in the operator's desktop and files there, under
the risk ADR 0234 accepts. A server may follow: it is moved by stating the fact.
> **Progressive insight — 2026-10-10.** This sentence named the control node's agent account `agent`. The
> operator chose `nox` instead. The name is the account argument of `mesh-controller node agent-account`,
> a node fact stated at the controller's terminal, so no code and no other part of this record changes:
> the control node still names an agent account of its own, which cannot become root without a person.
### 2. The account is declared and has no way to root
The `claude-code` module declares the account as a `user` resource named `${machine:agent-account}`, with
@@ -379,6 +384,10 @@ works on branches and pull requests; what it may merge is said under *What still
runs as it can still become root: the tool runner and the operator's own shells. No agent runs as it.
- **Not judged, said plainly:** the ways §3 lists under *not judged*. Each is a way to root this record does not
see. None is known on the control node, and each is left for a later record.
> **Progressive insight — 2026-10-10.** That later record had no issue tracking it. The ways §3 lists under
> *not judged* are tracked by [issue 344](../04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md)
> (gap 14), beside the other gaps in the agent's boundary accepted for now.
- The tool runner's credential still carries every co-hosted module's grants. The agent cannot read it.
- ADR 0181 decided one account per node. This record adds a second, of a different kind: not a person's
login, but the one agents run as.
@@ -153,6 +153,20 @@ The table of ADR 0239 decision 2 gains these rows:
never say it reached every machine. mesh-delivery itself, on the control node alone, is such a module: a
held merge of it stays held for a person, as before this record. Reading "every machine running it" from
the first machines when they are all the machines would close it, and is left for a later change.
> **Progressive insight — 2026-10-09.** The one-machine case above was left for a later change with no
> issue, owner or date, which is how ADR 0244's deferred renames went unnoticed (issue 354). It is tracked by
> [issue 355](../04-ISSUES/355-a-held-delivery-of-a-module-that-runs-on-one-machine-only-is-never-covered/00-report.md),
> owned by mesh-delivery in the controller's repository. The decision stands.
> **Progressive insight — 2026-10-09.** *The gap was wider than one machine.* A build the mesh held back for
> a person and sent on the operator's word travels by a backlog walk, whose record gives one step per
> machine and no "every machine running it" or "the rest running it"; and that walk's last record keeps only
> the machines it is done on, which mesh-delivery read for nothing. So a held delivery whose module reached
> every machine that way was never covered either
> ([issue 351](../04-ISSUES/351-a-held-delivery-whose-module-a-backlog-walk-carried-is-never-covered/00-report.md)).
> Since its fix, a finished backlog walk passes the steps it carried, and a passed step on every machine the
> held delivery's plan sends the module to counts as sent everywhere. The one-machine case stays as stated.
- **A group member waits longer, rather than being held too soon.** A composed check that never answers leaves
the member in `published`. Its bound of 30 minutes lists it as stalled. That is the right owner for a check
that does not answer.
@@ -210,6 +210,27 @@ fails if one changes alone: if the operator's account lost its passwordless `sud
> 3. **An ordinary line is reads and settings.** §4 is exact as it stands: an ordinary line is the generic verb's
> reading forms, or a `settings` line composed as the settings verb composes it — nothing else runs.
> **Progressive insight — 2026-10-09, the operator's rule.** The operator types **one** command, `mesh-cli`
> (`nox`). `nox-mesh-host` (the node-engine) and `mesh-controller` (the controller) are daemons, and a person
> never types their names. The consequence above — a record that says to type `mesh-controller <command>` means
> `mesh-cli <command>` — is from this day a rule on every line the mesh addresses to the operator: a condition's
> words, a verb's answer, a module's README, a design document name a `nox` line, never a daemon's. Found on
> 2026-10-09 at 18:01 by the operator typing the node-engine's `hand-over` of
> [issue 339](../04-ISSUES/339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md), which a condition
> had told them to run "at the machine": the command on the machine's `PATH` was a copy of an engine from before
> that subcommand existed, and the act had no channel
> ([issue 356](../04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/00-report.md)).
> Now `nox node hand-over <node> <path>` on the control-node is that act, terminal-only, carried by the controller
> to the node's engine over the bus. **How it is checked:** hq's `words.py` refuses a daemon's line —
> `mesh-controller` or `mesh-host` followed by one of its commands, and `nox-mesh-host hand-over` — in the
> documents it checks from this day, quotations excepted, code blocks and spans included; the catalogue's
> `checks/words` refuses the same lines in every string a module's tools can show and every manifest description,
> from its copy `retired-lines`, which `words.py --list lines` prints and compares. **What is not a line here:** the
> node-engine's install-time subcommands (`nox-mesh-host key`, `enrol`, `overlay take`, `reconcile`, `apply`,
> `profile`, `inventory`, `owned`, `bundle`, `version`) are typed as root at a machine no mesh reaches yet, or at
> one no mesh can reach any more; the engine's usage says so. The daemons' binaries keep their names. The decision
> stands.
### 5. What comes back
**Every line is a call**, run, recorded and answered as a seat's verb is: the bus lets a request be answered for a
@@ -0,0 +1,425 @@
---
topic: what runs on it
status: accepted
date: 2026-10-09
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md
---
# 273. The agent's home is the module's, and the operator's personal rules and lessons are registered through it
## Context
[ADR 0216](0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)
put the coding agent's configuration under its module at three scopes, and gave the home scope the lightest
touch: the module "owns only what it placed" (§5), reports what it did not place and can import it (§6), and
"the stale files already in the homes stay until the person removes them". That followed
[ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md): inside a home, the
person's own rules and skills are *found*, never read, rewritten or removed.
[ADR 0243](0243-the-agent-module-removes-a-home-item-it-did-not-place-only-on-the-persons-word-and-keeps-a-copy.md)
added the one channel for removing a found item, on the person's word, with a copy kept.
[Issue 340](../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md) then
found that the module's tools could not tell the operator from an agent, so an agent confined on the
control-node could register a hook, or a rule, that the operator's next session on another node would run or
obey. The operator closed it on 2026-10-09: shared text and everything that runs is set at the controller's
terminal alone, and the home scope is written only into the home of an agent account of its own
([ADR 0266](0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md)),
never into the operator's. The same day
[ADR 0272](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)
gave the operator a way to that terminal, and
[ADR 0259](0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md) gave
any module a way to ask the operator a question and act on the answer with a warrant.
**What the homes hold**, measured on 2026-10-09 through each node's own status tool
([research 040](../01-RESEARCH/040-the-agents-home-is-the-modules-and-the-operators-rules-and-lessons/01-what-the-home-holds-and-the-options.md) §2):
- a skill [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §1 retired on 2026-10-02
("nothing; it named the predecessor's forge") is still in the home on **all four nodes**;
- a person's instruction file is on three; a subagent on one;
- the operator's two hand-written rule files, about how the operator wants text handed over and how a second
forge's repositories are worked in, are on **the laptop alone**;
- the mesh has placed nothing in any home, and raises no condition about any of it.
On the laptop the directory holds 33 entries: 7 are configuration (the two rules, five skills and a synced
folder, an empty commands folder, a settings file with 6 keys, a local settings file the program writes with
the permission answers a person gives, the program's plugin records), and the rest is the program's own
working state — history, sessions, the memory of 30 projects (1.5 GB), caches, the credentials file
([research 040](../01-RESEARCH/040-the-agents-home-is-the-modules-and-the-operators-rules-and-lessons/01-what-the-home-holds-and-the-options.md) §1).
**What the agent learns, and where it keeps it.** The agent keeps notes for itself in the program's memory
for one repository on the laptop: 45 on 2026-10-09, at least 9 of them a correction the operator gave of how
the agent works — finish a module before pushing, use the mesh's tools and not ssh, read the merge gate before
merging, no work-arounds for a missing tool. Each was said once, sometimes with irritation, written by the
agent in its own words, shown to nobody, read on no other node and in no other repository, and not said again
at the moment it matters ([research 040](../01-RESEARCH/040-the-agents-home-is-the-modules-and-the-operators-rules-and-lessons/01-what-the-home-holds-and-the-options.md) §3).
**The operator's direction, 2026-10-09**, in their words:
> "the home-scope does own ~/.claude, so it should be completely empty and manual edits should be forbidden.
> 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"
> "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."
The record was searched first ("personal rules lessons frustration self-learning"; "hand edit home .claude
drift condition"): nothing.
### Against GENESIS
- **The mesh owns the truth.** "State lives in the mesh and is derived onto nodes. A file edited on a node
is a bug with a delay on it." A rule file written by hand into the agent's home is that file.
- **The mesh remembers.** "Nothing has to be explained twice." A correction the operator gives once reaches
every session of theirs, and is said again where it applies.
- **Failure must be loud.** A hand edit is a condition, not a silent difference the next render erases.
- **Nodes are personal; the mesh is a guest.** The program's own working state — memory, history,
sessions — stays the program's, excluded by name, exactly as ADR 0182 drew the line.
## Considered Options
The options are weighed in full in
[research 040 §5](../01-RESEARCH/040-the-agents-home-is-the-modules-and-the-operators-rules-and-lessons/01-what-the-home-holds-and-the-options.md);
in short:
### Who owns the configuration under the home
1. **As today: the module owns what it placed, the rest is found** (ADR 0216 §5). Rejected by the operator,
and by the measurement: a rule on one node reaches no other, a retired skill stays on four, nothing is
raised. And the guard refuses ssh to a machine but not a write to `~/.claude/rules/`: 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, as ADR 0182 rejected it: the
working state is the program's, and a render touching the projects' memory erases a season of it.
3. **The module owns every configuration file by name, excludes the 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 only**, the home scope as ADR 0216 defines it. Rejected as the only scope: the operator is
one person on four nodes.
2. **Mesh-wide instruction sections.** Rejected as their place: those are the mesh's word about the mesh,
written at the terminal. A personal rule is the operator's word about the operator, 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, rendered into the agent's home on every
node.** Chosen; the same scope carries 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 text every session reads is written on the operator's
word, never an agent's (issue 340).
2. **Lessons stay in the agent's memory.** Rejected by the measurement above.
3. **A lessons register in the module; a lesson is proposed by an agent, accepted or declined by the
operator, rendered as a personal rule on a yes, and said again before the act it bears on.** 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, and kept as one of two ways; alone, it makes a yes in a
session a trip to the control-node.
3. **A warrant on an authorising ask** (ADR 0259), the exact text bound in the ask. Chosen beside 2.
## Decision
### 1. The configuration under the agent's home is the module's, whole
**What "completely empty" means.** The operator's words, "it should be completely empty", are read as: the
agent's home holds **nothing a person or an agent put there by hand**. Every configuration file in it is the
module's rendering, and the only other entries are the program's own working state, named below.
**The `claude-code` module owns every configuration file under the agent's home directory** (`~/.claude` of
the account the agent runs as, ADR 0266 §5): the home instruction file, `rules/`, `skills/`, `commands/`,
`agents/`, `hooks/`, `output-styles/`, `settings.json` and `keybindings.json`. **Nothing there is written by
hand.** Each is rendered by the module's code from registrations, on every node, whenever a registration
changes, and nothing else writes them. A configuration file the vendor adds later joins the list by name, in
the module, before the module renders anything into it.
**The program's working state is excluded by name**, and stays the program's, in ADR 0182's *found* class
or *written by the module's own process*: the credentials file `.credentials.json` (ADR 0183), the history,
the sessions and their environments, the projects directory with every project's memory, `todos/`,
`plans/`, `statsig/`, the shell snapshots, the file history, the debug, cache, paste and telemetry
directories, the statistics, the daemon's files (`daemon/`, its lock, log and status files,
`daemon-auth-status.json`, `daemon-auth-cooldown`), the program's dot-markers (`.last-cleanup`), the
vendor's synced skills (`skills/synced/`), and the backups, worktrees, jobs, feedback, state and IDE
directories. The module never writes or removes any of these, and names the list in its manifest's
documentation.
**Two entries are the program's to write, and watched**, because each decides what a session runs and
nothing else would see a change to it: `settings.local.json`, where the program records the permission
answers a person gives in a session, and `plugins/`, the program's record of its marketplaces and installed
plugins, and their cache. The module never writes them; §4 says what it raises and what the guard refuses.
**What is neither, by name.** Measured on the laptop on 2026-10-09: `.hal-binding.json`, a leftover of the
retired predecessor, and `.credentials.json.testbak`, a copy of the credentials file left by a test. **No
copy of the credentials file** (`.credentials.json` followed by anything) belongs in the home: each is
reported, and removed on the operator's word with ADR 0243's tool, never kept as a copy in the module's
state, because it holds a token. **Every other entry the module knows under neither list is a condition**
(§4), so a new vendor file is a question the operator answers, not a default.
**The keys the program itself writes into `settings.json` on a person's choice** — the model, the spinner,
the drafts and the other preferences the vendor's own `/model` and `/config` commands set — are not hand
edits. The module keeps a short list of them, preferences only. When the program changes one of those keys,
the module takes the new value over into the operator's registered settings (§2), reports it in the status
tool, and the change follows the operator to every node. That is ADR 0182's *written into* class, applied
the other way round: the file is the module's, and the program is allowed into a few named keys of it. This
is the mechanism that keeps a `/model` choice from being reverted, the failure ADR 0182's context records.
**A key that decides what runs is never taken over**: `hooks`, `permissions`, `mcpServers` and the other
tool-server keys, `env`, `apiKeyHelper`, `statusLine`, `autoMode`, and every key not on the list. Changed on
disk, it is written back on the look that finds it (§4).
**`autoMode` is a permission rule.** It tells the program's unattended mode what it may do without asking,
which ADR 0213 calls policy, not preference. It is barred from the operator scope like `permissions` and
`hooks`, and belongs in the managed settings, set at the controller's terminal in the module's
`managed_settings` for the mesh or a node.
### 2. A fourth scope, the operator's, and a new kind, the personal rule
ADR 0216's three scopes stand. A fourth joins them: **operator**. An item registered at the operator scope
is rendered into the agent's home on **every node that names an operator account** (ADR 0181): into the
operator's own home where the agent runs as the operator, and into the agent account's home where the node
names one (ADR 0266). The mesh has one operator; the scope is the person, not a machine.
The kinds at the operator scope:
- **personal rule** (new): one short text in the operator's words about how the operator wants the agent
to work with them, rendered as one file under `rules/` and listed in the home instruction file, which the
module renders as the index of the personal rules, each with where it came from (the terminal, or the
lesson it was accepted from) and when. The vendor reads both into every session of that account;
- the operator's own **skills, commands and subagents**, under the person's own names, without the plugin's
prefix;
- the operator's **settings** (`settings.json`), as one registered object of the preference keys of §1,
laid under the managed settings as the vendor lays them; and the operator's **keybindings** and **output
styles**.
**Never at the operator scope: a hook, a permission rule (`autoMode` included), a tool server, an
environment or key-helper setting, or an item that runs anything.**
Those stay the terminal's, in the managed settings and the managed tool-server file (issue 340). A personal
rule, skill, command or subagent that holds a shell line or the front matter issue 340 refuses is refused at
the operator scope too.
**The operator's existing items are imported as the first registrations**, through the import tool ADR 0216
§6 named, at the operator scope: the two rule files on the laptop become the first two personal rules; the
laptop's skills and the workstation's subagent are imported or removed, each on the operator's word
(ADR 0243); the preference keys of `settings.json` on the laptop become the operator's registered settings,
and its `autoMode` key moves to the managed settings at the terminal, because it is a permission rule (§1);
the two leftovers §1 names are removed on the operator's word; the retired `cleanup` skill and the three
instruction files are removed with a copy kept. Until each item is imported or removed it
is a condition (§4).
### 3. Who may register into the operator's home, and issue 340's protection stands
A registration at the operator scope, and a registration at the home scope into an operator's own home, is
taken **only** by one of two routes, and refused through any other:
1. **At the controller's terminal** (ADR 0272), as issue 340 made shared text: the module's settings gain
`personal_rules`, `personal_skills`, `personal_commands`, `personal_agents` and `personal_settings`, set
with `nox settings set claude-code …` on the control-node. The module's whole layer is already the
terminal's, because its settings file is a mergeable file issue 340 counts as trusted.
2. **On a warrant** (ADR 0259): the module is an asker. Its register, import and remove tools, called at the
operator scope or for an operator's home, do not act; they publish an **approve** ask whose words show the
item in full and whose digest binds the exact text, name, kind and scope, and they act once on the warrant
for that ask, recording it. Until a channel that proves its sender answers approve for the module, the
ask is refused at once with the reason and names the terminal's line.
**The module's tools stay the operator's way in.** A tool called for the operator's home, while no channel
answers approve, does not only refuse: it records the request as **pending** in the module's state (what,
at which scope, asked when, from which node) and answers with the exact line to type: `nox settings set
claude-code '<json>'`, the request merged into the module's current layer, so that typing it changes nothing
else. The status tool lists pending requests; one is dropped when the terminal's layer holds it, or after
7 days. An agent asking for a rule therefore hands the operator one line to check and type, never a
description to translate.
The home scope into **an agent account's own home** stays as issue 340 left it: that node's own tools take
it, because that home is no person's. A home-scope item never shadows a personal rule: the module renders
the operator's items first and refuses a home-scope name an operator-scope item already uses.
So an agent still cannot plant anything in the operator's home: a tool call without a warrant writes
nothing there, a warrant is the router's record of the operator's own answer, and the terminal is the
operator's own account on a node where no agent runs as it.
### 4. A hand edit is a condition, and the guard refuses it
**On every look**, at least once a minute and at once on a change on disk, the module compares the
configuration paths of §1 with what it last rendered, and reads the two watched entries. Any difference is a
**finding**, said as the module's own health (ADR 0240) on that node, and the controller raises the
condition **`agent-home-drift`** for it, a warning that needs the operator, naming each path and what
differs. **The look that finds a difference acts on it**; nothing waits for the next render:
- **a rendered file changed by hand**, `settings.json` above all: written back on that look, the hand version
kept as a copy in the module's state (`home-copies`) and shown by the status tool, so the words are not
lost. The one exception is a preference key of §1 the program changed, which is taken over. A `hooks`,
`permissions`, tool-server or other key that decides what runs, added or changed in `settings.json`, is
**always written back**, never taken over;
- **a rendered file missing**: rendered again on that look, and reported once;
- **a file nobody rendered** under `rules/`, `skills/`, `commands/`, `agents/`, `hooks/` or
`output-styles/`:
- **before the node's first adoption** (the imports of §2, done once per node by the operator, and recorded
in the module's state): left in place and reported, never removed by the module. The operator's answers
are the import tool (register it at a scope) or the removal tool (ADR 0243: a reason, a copy kept);
- **after it**: **moved** on that look into `home-copies`, kept and restorable with ADR 0243's restore tool,
and raised. A session never loads a file nobody registered for longer than one look. Importing it from
the copy is the operator's way to keep it;
- **an entry the module knows under neither list of §1**: reported and raised, never touched; the operator
removes it on their word or the module's next build names it;
- **`settings.local.json` holding any key other than `permissions`**: raised, naming the key. The program
writes only that key there, so another is somebody's hand;
- **`plugins/` naming a marketplace or an installed plugin other than the vendor's own marketplace and the
mesh's `nox-mesh`**: raised, naming it. A plugin carries hooks, so one installed by hand runs in every
session.
The condition clears on the first look that finds no difference. It is never urgent, and the first-node gate
never reads it as a fault of a build (as
[ADR 0254](0254-a-wait-for-a-persons-new-login-is-not-a-gates-failure-and-each-module-of-a-send-keeps-its-own-pass.md)
treats a wait for a person).
**The guard on the agent's shell** (ADR 0245) refuses, before it runs, naming the module's tool that does the
job and saying that a rule the agent wants is a lesson (§5):
- any shell command or file edit whose target is under a configuration path of §1 — a write, an append, a
copy or move into it, an in-place edit, a removal;
- the same aimed at `settings.local.json` or at a `.json` file under `plugins/`;
- `claude plugin install`, `claude plugin marketplace add` and the in-session `/plugin install` and
`/plugin marketplace add`, whatever the plugin.
The operator's override of ADR 0245 §5 applies and is recorded. The guard does not watch the rest of the
working state.
### 5. Lessons: an agent proposes, the operator decides, and the agent is reminded
**The module keeps a lessons register** in its state (ADR 0201): one record per lesson, holding when and on
which node it was proposed, the session that proposed it where the agent gave it, the **trigger** (the
operator's words, quoted), the **cause** (what the agent did, in one or two sentences), the **rule** that
would prevent it (written as a personal rule, in the operator's second person: "do not…", "always…"), an
optional **before** (a tool name, a command prefix or a path pattern the rule bears on), and its state:
`proposed`, `accepted` or `declined`, with who decided, how and when.
**When an agent records a lesson.** The mesh's instruction section tells every agent: when the operator
expresses frustration (repeats a request, says they have asked before, says "again", "stop", "why did you";
irritation in any words), or corrects a way of working ("don't", "always", "from now on", "never"), the agent
first works out why — what it did, and what the operator wanted instead — and then records a lesson with
the trigger, the cause and the rule, in the same turn, before going on. It says in its answer that it did,
in one line. An agent that is unsure whether a correction is a lesson records it; the operator declines what
is not.
**A correction goes to the register, not to the agent's own memory.** The program's per-project memory
(§1, working state) stays the agent's notebook for facts about a project; a way of working the operator
corrected is a lesson, so that it reaches every node and the operator sees it.
**The first lessons are the agent's existing corrections.** Once the register exists on a node, the agent
there proposes, unasked, every note in its own memory that records a correction the operator gave (at least 9
on the laptop on 2026-10-09), each as a lesson with the note's trigger, cause and rule, and says so in one
line. The operator accepts or declines them like any other.
**A proposed lesson is not a rule.** It is rendered nowhere and reaches no session as an instruction. The
module shows it to the operator for a yes: an approve ask (§3 route 2) whose words carry the trigger, the
cause and the rule, with Accept and Decline as its options; or, until a channel answers approve, the list
tool names the proposed lessons and the operator accepts or declines each at the terminal (`nox settings
set claude-code …` with the lesson's id in `lessons_accepted` or `lessons_declined`). A proposed lesson
expires after 30 days, declined with that reason.
**An accepted lesson is a personal rule.** The module registers it at the operator scope with the lesson's
id as its source, so it renders into the agent's home on every node like any personal rule, and the home
instruction file names it as "accepted from lesson N on <date>". **A declined lesson stays in the register
with its reason**, and the list tool shows it, so an agent does not propose it again; a new lesson whose rule
matches a declined one's is refused with the reason, and the agent is told to say so to the operator rather
than record it again.
**The agent is reminded, before it acts.** Two things make the lessons proactive rather than a file the agent
may or may not open:
- **at session start**, the plugin's session-start hook (the module's own binary, beside the guard) prints
the personal rules accepted from lessons in the last 30 days and the ones whose `before` matches the
session's working directory or repository, each in one line, so the session begins knowing them;
- **before a tool runs**, the guard (ADR 0245 §4, asked about every tool) prints the rule of every accepted
lesson whose `before` matches the tool, the command or the path about to be used — a reminder, never a
refusal; a refusal is a guard rule and nothing else. The reminder is printed once per session per rule.
**The register is readable by any agent on any node** through the list tool, and shows `proposed`,
`accepted` and `declined` apart, each marked, so a session reading it cannot take a proposal for a rule.
Recording a proposal needs no yes: it writes the register and nothing a session obeys.
### 6. What this changes in the earlier records
- **ADR 0216 §5 and §6**: the mechanism changes (that record gains a note). The home scope owns the
configuration whole; a found item is reported as a condition rather than listed on request; import stays
the way in; the plugin's hook file holds the module's own hooks, the guard and the session-start
reminder, and nothing registered.
- **ADR 0243** stands as the removal on the person's word, now on a warrant or at the terminal for an
operator's home, and is the operator's one answer to a found item beside importing it.
- **ADR 0182** stands: the class table holds, with the configuration files in the *owned* class and the
working state *found*, each named.
- **ADR 0245**: the guard gains a refusal (§4) and a reminder (§5).
- **Issue 340**: the protection stands; the terminal and the warrant are the two ways into the operator's
home.
## Consequences
- The operator's rules follow the operator: a rule written once reaches the agent's home on every node
within a minute, and a session on any node starts with it.
- A hand edit under `~/.claude` is undone within a minute, with the hand version kept, and is said as a
condition: no silent difference, and no lost words.
- The retired skill and the three stale instruction files, found on four nodes since 2026-10-02, are
removed on the operator's word with copies kept, and nothing like them can stay unnoticed again.
- A correction the operator gives once becomes a rule everywhere on one yes, and is said again before the
act it bears on. The operator sees every rule the agent proposes, and can decline it.
- **What got harder:**
- a new rule for the agent is no longer a file: it is a registration at the terminal or a warrant on an
ask. Until a channel answers approve for the module, every yes is typed on the control-node;
- the module watches the home on every node, and its state holds the lessons register and the copies of
hand versions. Clearing old copies is not decided here, as ADR 0243 left it;
- a vendor file the module does not know is a condition until it is classified, which is the point: an
unknown file in the agent's home is a question, not a default;
- an agent is expected to notice frustration and record a lesson in the same turn, which costs the turn a
tool call. A lesson recorded late, or not at all, is found by reading the session against the register.
- **Not decided here:** a per-project lesson (one bound to a repository rather than the operator); lessons
for an agent the mesh runs rather than the operator's; and a channel of the operator's own that can
tell the operator from an agent (issue 340's "once we build a UI"), which would become a third route.
> **Progressive insight — 2026-10-09.** The four things this section leaves undecided — clearing old copies
> of hand versions, a per-project lesson, lessons for an agent the mesh runs, and a channel of the operator's
> own — were left with no issue, owner or date, which is how ADR 0244's deferred renames went unnoticed
> (issue 354). They are tracked by
> [issue 357](../04-ISSUES/357-what-adr-0273-left-undecided-has-no-owner/00-report.md). The decision stands.
## How it is checked
| Rule | Checked by |
|---|---|
| Every configuration path of §1 is rendered from registrations and nothing else; the working state is never written or removed; an entry under neither list is a condition | the module's render test over a seeded home: the configuration files (`keybindings.json` and `output-styles/` included) match the registrations byte for byte; every excluded entry (`todos/`, `plans/`, `statsig/`, `.last-cleanup`, `daemon-auth-cooldown` among them) is byte-identical before and after; an unknown entry, `.hal-binding.json` and a `.credentials.json.*` copy each raise `agent-home-drift` and are left in place; removing a credentials copy keeps no copy of it |
| A program-written preference key of `settings.json` is taken over; a key that decides what runs is always written back; `autoMode` is never taken at the operator scope | the module's test: the model key changed on disk appears in the operator's registered settings on the next look and in the rendered file on every node; `hooks`, `permissions`, `mcpServers`, `env`, `apiKeyHelper`, `statusLine` and `autoMode` added or changed on disk are each written back on the look that finds it and never reach the registration; `autoMode` in a `personal_settings` value is refused |
| A personal rule renders into the agent's home on every node naming an operator account, under `rules/` and in the home instruction file | the module's render test for one machine of three: the operator's own home on two, the agent account's home on the one naming one; live: a rule set at the terminal is in a new session's context on each node |
| Nothing that runs is taken at the operator scope | the module's tests: a hook, a permission rule, a tool server, and a skill, command or subagent holding a shell line or the refused front matter are each refused at the operator scope with issue 340's reason |
| A registration into an operator's home is taken only at the terminal or on a warrant | the module's tests through its real tool dispatch: each register, import and remove tool at the operator scope, or for an operator's home, publishes an ask and writes nothing; the same with a warrant for that ask acts once; a warrant for another ask, or a replayed one, is refused; while no channel answers approve the ask is refused at once naming the terminal's line. The controller's test: the module's layer is the terminal's (issue 340's) |
| A hand edit is a condition, undone on the look that finds it | the module's tests with a clock: a rendered file changed by hand (`settings.json` among them) is written back on the same look, within a minute, its hand version kept and shown; a missing one is rendered again on that look; the condition clears on a clean look; the controller's test that `agent-home-drift` is a warning the first-node gate never reads as a fault |
| Before a node's first adoption a file nobody rendered is left; after it, it is moved to the copies and raised | the module's tests: an unrendered file under each of `rules/`, `skills/`, `commands/`, `agents/`, `hooks/`, `output-styles/` is reported and left before the adoption is recorded; after it, the same file is gone from the home on that look, present in `home-copies`, restorable with the restore tool, and the condition names it |
| `settings.local.json` and `plugins/` are watched | the module's tests: a key other than `permissions` in `settings.local.json` raises the condition naming it; a marketplace or installed plugin other than the vendor's and `nox-mesh` in `plugins/` raises it naming it; neither file is written by the module |
| The guard refuses a write under a configuration path, to `settings.local.json` or `plugins/*.json`, and a plugin install, and lets the working state alone | the module's guard tests: a redirect, append, copy, move, in-place edit and removal under each path, and aimed at `settings.local.json` and a `.json` under `plugins/`, refused naming the tool; `claude plugin install`, `claude plugin marketplace add` and their in-session forms refused; the same write under `projects/` passes; the operator's override honoured and recorded |
| A home-scope name may not shadow a personal rule | the module's test: with a personal rule, skill, command or subagent registered at the operator scope, a home-scope registration of the same name and kind is refused naming the operator-scope item, and the render holds the operator's item |
| A tool called for the operator's home leaves a pending request and the exact terminal line | the module's tests: the call writes nothing into the home, records one pending request, and answers a `nox settings set claude-code '…'` line which, applied to the current layer, adds that item and changes no other key; the request is dropped once the layer holds it, and after 7 days |
| A lesson is recorded with its trigger, cause and rule; a proposal renders nowhere; an accepted one is a personal rule; a declined one is not proposed again | the module's tests over the register: register, list, accept, decline, expiry; the render test that only accepted lessons reach `rules/`; a second proposal matching a declined rule refused with the reason |
| The agent is reminded at session start and before a matching tool | the module's hook tests: the session-start hook prints the recent and matching rules; the guard prints a matching lesson's rule once per session and refuses nothing for it |
| An agent records a lesson when corrected, and proposes its existing corrections first | the module's render test: the rendered managed instruction file's `working-on-the-mesh` section holds the paragraph on when a lesson is recorded, that a correction goes to the register and not to the agent's memory, and that existing corrections are proposed first, and the test fails when the paragraph is taken out of the section; live: a correction given in a session is in the register within that turn, and the laptop's memory corrections are in the register as proposals after WP9 |
| Live, at acceptance (the operator's view) | on the laptop: `~/.claude` holds the rendered configuration and the program's working state and nothing else; a rule written by hand into `rules/` raises `agent-home-drift` within a minute and is listed by the status tool; `nox settings set claude-code '{"personal_rules": …}'` on the control-node puts the rule into a new session on every node; a correction given in a session appears in the lessons list, is accepted at the terminal, and the next session on another node names it at start |
## References
- [Research 040](../01-RESEARCH/040-the-agents-home-is-the-modules-and-the-operators-rules-and-lessons/00-overview.md):
the measurement on four nodes, the agent's memory, the options.
- [ADR 0216](0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md),
[ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md),
[ADR 0243](0243-the-agent-module-removes-a-home-item-it-did-not-place-only-on-the-persons-word-and-keeps-a-copy.md):
the home before this record.
- [ADR 0245](0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md): the guard.
- [ADR 0259](0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md),
[ADR 0272](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):
the two ways the operator's word reaches the mesh.
- [ADR 0266](0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md),
[ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md): whose home the agent's is.
- [ADR 0240](0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md),
[ADR 0254](0254-a-wait-for-a-persons-new-login-is-not-a-gates-failure-and-each-module-of-a-send-keeps-its-own-pass.md):
the condition and the gate.
- [Issue 340](../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md): the protection kept.
- [To-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §1, §8 and §10,
[to-be 40](../03-DESIGN/01-to-be/40-building-the-operators-agent-and-its-licence-manager.md) WP7–WP10: the design and the build.
@@ -0,0 +1,299 @@
---
topic: what runs on it
status: accepted
date: 2026-10-10
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md
---
# 274. An agent proposes a shared instruction section, and every node takes it only on the operator's warrant, which it proves itself
## Context
[Issue 340](../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md) found that the
agent module's tools cannot tell the operator from an agent: the tool runner acts for any caller of the mesh MCP
server and is not told who called. An instruction section registered for every node lands in the managed instruction
file, which every Claude Code session on those nodes reads first, as the mesh's own word. Anything that could write
one could ask the operator's agent on every node to act for it, at the next session there: a prompt injection that
stays. The operator decided on 2026-10-09 that shared text is set at the controller's terminal, in the module's
settings, "for now", and named the way back: "once we build a UI, we can also update the context through it". A
surface of the operator's own, which can tell the operator from an agent, was the intended way.
That surface exists now. [ADR 0259](0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md)
lets any module ask the operator a question and act on the answer with a **warrant**: the router's record that a
person chose one option of one ask, published on a subject only the router may publish and kept in a record only the
router writes. An option at the level **approve** is carried only by a channel that proves its sender (Telegram,
linked to the operator's account, placed where no agent can become root), so a warrant for it is the operator's
own answer. The controller already acts on warrants, and its `rehearse` command shows the whole path live.
The operator asked on 2026-10-10: "our claude-code module should have mcp-tools to add mesh-shared
instructions/context". Agents keep shared text up as they work: a rule learned, a hand-over from one session to the
next on another node, the state of a piece of work. Today each of those is a trip to the control-node's terminal,
a line typed with the text as a list of lines, which an agent can only hand the operator to type.
The record was searched first ("propose instructions approval warrant", "shared instructions agent hand-over
context"): nothing beside ADR 0273, whose §3 route 2 already puts the operator's personal rules behind a warrant on
an authorising ask, and issue 340.
**Where it would land, measured in the module's code on 2026-10-10.** A registration is a key in the module's
`config` state on the bus. Every node's tool runner that carries the module writes that state, and every node renders
what it finds there. So a record on the bus proves nothing about who put it there: a warrant copied into the bus beside
a text is a string anyone with a tool runner's credential could write. Issue 340 lists the same fact as still open
for text already there ("Text at the mesh or node scope written straight into the bus state still renders").
### Against GENESIS
- **Failure must be loud.** A proposal nothing can carry to the operator is refused at once, in words, never left
waiting for an answer that cannot come.
- **The mesh owns the truth.** The approved text is the mesh's, rendered on every node it is for, not a file an
agent edits.
- **The mesh remembers.** A hand-over written once reaches the next session on every node, so nothing has to be
explained twice.
## Considered Options
### Who may make a shared section active
1. **The tool's caller, as before issue 340.** Rejected in issue 340: the caller may be any agent.
2. **The controller's terminal alone** (issue 340's decision). Safe, and kept as one of two ways. Alone, it makes
every shared text a trip to the control-node, and an agent can only describe what to type.
3. **The operator, on a warrant: an agent proposes, the operator approves on a channel that proves who answered.**
Chosen, beside 2.
### Who proves the warrant, and where the approved text is kept
1. **The instance that hears the warrant writes the text into the module's state on the bus**, as a registration,
and every node renders it. Rejected: the state is written by every tool runner carrying the module, so every node
would render whatever is written there with a warrant's shape beside it. That is issue 340's open item, widened.
2. **The controller verifies the warrant and writes the text into the module's settings**, the terminal's own store.
Weighed. It is durable, and a node that joins later gets it. But it makes the controller write a key it keeps for
the terminal on another module's ask, and lets any asker's warrant write terminal-only settings once that path
exists. It also takes a module's own decision (what its ask says, what its answer does) into the controller.
3. **Every node proves the warrant itself and keeps the approved text in a file of its own.** Each instance reads
the router's own record of the ask under the module's name, composes the ask again from the proposal, holds the
warrant to it, and only then writes the section into its own file, which is what it renders. Chosen. A record
written into the bus by anyone else becomes active nowhere, and a record deleted from the bus removes nothing.
### What "context" is
1. **A separate managed file of shared context** that sessions are told to read. Rejected: a second file, a second
way to reach a session, and a second thing to prove, for text the instruction file already carries.
2. **An instruction section.** Chosen. A hand-over, a rule or the state of a piece of work is a named section, read
first in every session it reaches, approved, replaced and removed like any other. One mechanism.
### Which kinds may be proposed
1. **Every text kind** (sections, output styles, skills, commands, subagents). Rejected for now: a skill is invoked
by the model on its own when its description matches, may carry scripts, and its front matter decides what runs.
Approving one on a phone is a different judgement from reading a paragraph.
2. **Instruction sections only.** Chosen. The other kinds stay the terminal's, as issue 340 left them.
### How the operator reads what they approve
1. **The whole text on the phone, always.** Rejected: the router's content rule refuses a path, an address or a
long unbroken word in any message that leaves the mesh, and a message has a length bound.
2. **A fingerprint only.** Rejected: nobody approves what they cannot read.
3. **The whole text when it is short and every line may leave the mesh; else the first lines, the size and a
fingerprint, and the line that shows all of it at the controller's terminal.** Chosen.
## Decision
### 1. An agent proposes; nothing is active until the operator approves
The agent module (`claude-code`) gains two tools:
- **`claude_code_instructions_propose`** takes a scope (`mesh` for every node, or `node` and a list of nodes), a
name and the text, or `remove` for a section approved before. It refuses what issue 340 refuses and more (§5),
records the **proposal** in the module's `proposals` state (the action, the scope, the nodes, the name, the text,
the text's SHA-256 digest, the node it came from and when), and publishes one ask on the `operator-channel` seat
under the module's own name. It answers with the proposal's id, its fingerprint (the digest's first 24
hexadecimal digits, in groups of four) and the question the operator was asked. **It changes nothing on any node.**
- **`claude_code_proposals`** lists the proposals, newest first: what each does, where it reaches, its
fingerprint, and what this node made of it (waiting, approved, declined, discarded at expiry, refused). Given an
id it answers that proposal's whole text and digest. It also lists the sections approved on this node.
A proposal that would replace an approved section of the same name and scope says *Replace*. A removal is a
proposal too (§4).
### 2. The ask
One ask per proposal, composed from the proposal alone, so that every node composes the same one:
- **headline**: "Add Claude instructions '<name>'?" ("Replace", "Remove"), shortened to "Add Claude instructions?"
when the name makes it longer than the router allows, and ending "Part shown" whenever the phone does not show
the whole text;
- **explanation**, first line: "Add instructions '<name>' to every Claude session on the mesh?", or "on <the
nodes>"; then the node it was proposed from and when, the text's lines, bytes and fingerprint, and that every new
session there reads it as the mesh's own word until it is removed the same way; then **the whole text** when it is
at most 2000 bytes and 40 lines and every line may leave the mesh, or else "NOT SHOWN IN FULL here" and **its first
lines** (at most 20 lines and 1200 bytes), each line that may carry a path, an address or a long word replaced by a marker, and the line
that shows it all: `mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'` at the controller's
terminal, whose fingerprint must be the one on the phone;
- **two options, Approve and Decline, both at the level approve**, as the controller's rehearsal asks: only a
channel that proves who answered carries either, so a click on a channel that does not prove its sender ends
nothing. Each option binds the digest of its act: the proposal's id, action, scope, nodes, name, the text's
digest, where it came from and when, and the answer;
- **expiry**: a day less ten minutes after it was proposed; at expiry the proposal is discarded and nothing
changes. An ask that authorises never defaults (ADR 0259);
- **about**: the scope, the nodes and the name, so a newer proposal for the same section replaces an older one
still open, and the older one is discarded.
The router caps open asks at three per asker. A fourth proposal is refused by the module, in words, before it is
asked.
### 3. Every node proves the warrant itself
A proposal record on the bus proves nothing, because every tool runner carrying the module writes that state. So
**each node**, for each proposal it has not decided yet, on every word from the router, on every change of the
proposals, and once a minute:
1. reads the router's **own record** of the ask, under the module's name. The bus lets only the router write that
record, and lets the module read only its own asks (ADR 0259 §3). An ask still open is left for the next look;
2. judges the proposal as the module would have made it (§5's refusals, its name, scope, nodes, where it came from,
its action, and the text against its digest), because anything holding a tool runner's credential can write a
record and ask for it under the module's name; then composes the ask again from the proposal, and holds the
warrant to it with the SDK's own check: the same asker
and ask, **the ask's digest the router took**, a person's choice, an option the ask offered, at that option's
level, given before the ask expired. Then the chosen option's bound act must be this proposal's act, and the
text's digest must be the digest the proposal names. An ask published under the module's name with other words,
a proposal changed after it was asked, a warrant for another ask, or an answer at another level is refused and
said, and nothing is applied. A refusal is judged again on every look rather than kept, so an honest approval
refused for a reason that passes (a node still on an older version of the module) is taken once it holds;
3. on **Approve**, writes the section into **its own file** in the module's state directory, if the proposal reaches
this node (the mesh scope, or a node scope naming it), and renders. On **Decline**, an expiry, a cancel, a
replacement or the router's refusal it applies nothing. Either way **it records the proposal as decided and never
decides it again**: a warrant heard twice, or replayed into the bus, changes nothing;
4. takes closed asks oldest warrant first, and keeps, for each section, when the last change applied to it was
approved: **an approval older than that is never applied**, so an earlier approval read late, or replayed into the
bus, never undoes a later replacement or removal.
A section is its scope, its nodes and its name together: a proposal of the same name for other nodes is another
section, and replaces or removes nothing of the first.
**A record that cannot be read is never taken for an empty one.** The node's own file holding the approved sections
and every decision is written whole, synced to the disk before it takes the old one's place; when it cannot be
read, the node decides nothing, proposes nothing and renders no approved section, and says why.
The managed instruction file renders the approved sections under their own heading, "Instructions the operator
approved", each with where it was proposed from and when it was approved, after the mesh's own text, the
instructions table and the terminal's sections.
**What the operator approved and when is the router's record**, kept 30 days after the ask closes. A node that was
away for longer than that, or joins later, cannot prove an approval made before, and **does not render it**: its
`claude_code_proposals` says which approvals it could not prove, and the operator proposes the section again or sets
it at the terminal. That is the cost of option 3 and is accepted: an approval the node cannot prove is the case this
decision exists to refuse. The same holds the other way: **a removal whose record is deleted from the bus before a
node read it is not seen there**, so that node keeps the section; the node's list shows what it renders, and the
operator proposes the removal again.
### 4. A removal needs the same approval
An approved section is removed only by a proposal with `remove`, approved the same way, whose ask names the section,
its scope and nodes, and the fingerprint of the text it removes. A node removes the section only when the approved
removal names the text it holds. A proposal record deleted from the bus removes nothing, because the node's own file
is what renders. A section set at the terminal is changed there, and a proposal of the same name is refused.
### 5. What is refused, before anything is asked
- **What issue 340 refuses, kept**: a shell line (`` !`…` `` or a block opened with ```` ```! ````); front matter of
any kind, naming `hooks`, `mcpServers`, `permissionMode` or `allowed-tools` when it holds one; and **any `@` at the
text's start or after anything but a letter or a digit** (any whitespace, Unicode's included, or punctuation),
which the instruction file may read in as a file. Wider than the vendor's pattern on purpose;
- **what a reader cannot see and a session reads**: a control character but a line break and a tab, a format
character (zero-width characters, bidirectional overrides, the tag block), a variation selector, a letter that
shows blank, private use, a noncharacter, and any space or separator but the plain space. The operator approves what was shown, so nothing unshown may ride along;
- a heading of level one to three, in any of its forms (`#`, a line underlined with `=` or `-`, HTML), which would
read as one of the managed file's own sections;
- anything shaped like a secret (a key block, a known token's shape, a value given to a secret's name), because the
text is read by every session and shown on a phone;
- an empty text, one that is not text, or **more than 16 KiB**: every session reads all of it, every time;
- the home scope (an account's own directory keeps its own rules, ADR 0273) and `all` at the node scope (that is the
mesh scope);
- a name the controller's terminal sets;
- **when no channel that proves its sender is on the mesh** (no channel kind promising a choice, an exact rendering
and a verified sender): refused at once, naming the terminal's line instead. The router judges again when it takes
the ask, and refuses an ask none of its ready channels can carry; that refusal reaches every node as the ask's end,
and the proposal is discarded. **Nothing waits for an answer that cannot come, and nothing is active without one.**
> **Progressive insight — 2026-10-10.** This said the module refuses at once "when no channel that proves its
> sender is on the mesh", judged from the channel kinds in its own membership. The controller issues those kinds
> only to a module that uses or watches a kinded bench, and an asker uses the operator channel alone, so the module
> saw no kinds and refused every proposal on the live mesh, with Telegram linked and proven. Instead: the module
> refuses at once only when kinds are issued to it and none proves its sender; otherwise the router judges, as
> ADR 0259 §4 has it, and the module waits a few seconds for its word, says a refusal to the proposer, and
> discards the proposal on every node that hears it. A proposal the router keeps no record of after its ask's
> time is discarded too. The decision stands: nothing waits for an answer that cannot come, and nothing is active
> without one.
### 6. The warrant is recorded in the hand-act log
The controller gains the verb **`warranted`** (asker, ask, and nothing else). It reads **the router's own record**
of that asker's ask, never the caller's account of it, and records who chose, through which channel, with which proofs
and which answer, under the verb `warrant` that the hand-act log already counts as a person's decision, not a repair.
It records once per ask, under an id the ask decides, however many of the module's instances ask. It refuses an ask
still open, one that ended without a person's choice, and another asker's. Every node that decided an Approve or a
Decline asks it, again every ten minutes for a week while the controller has not taken it, so a node that is away
leaves nothing unrecorded.
### 7. Nothing proposed is logged
The module's log names a proposal by its id, action, scope, name and fingerprint. It never logs the text, and never
the ask's explanation, which carries it.
### 8. What this changes in the earlier records
- **Issue 340, decision 2** ("shared agent text is the terminal's, for now"): instruction sections at the mesh and
node scopes gain a second way, the operator's warrant, beside the terminal. The other kinds stay the terminal's.
The registering tools still refuse every shared kind and now name the proposal tool. The issue's still-open item
about text already on the bus is unchanged: approved sections never come from there.
- **ADR 0273 §3** stands. Its route 2, a warrant for the operator's home, is built the way §3 here proves a warrant:
on every node, from the router's own record, against an ask composed again from what was proposed.
## Consequences
- An agent hands work over to the next session on any node with one tool call and one tap on the operator's
phone. The operator sees the text, or its first lines and a fingerprint, before it reaches a session.
- No agent, and nothing holding a tool runner's credential, can make text active in another session: what it
writes to the bus is a proposal, and every node checks the router's own record against the exact text.
- **What got harder:**
- every shared section is a question to the operator, at most three open at once;
- a long text is read at the controller's terminal before it is approved;
- a node away for more than 30 days, or joining later, does not render a section approved before, and says so;
- each node keeps its own copy of what it approved, so `claude_code_proposals` on one node speaks for that node;
- the module now asks the operator, so it uses the `operator-channel` seat, and its grant on the bus grows by
the asker's subjects and its own records there.
## How it is checked
| Rule | Checked by |
|---|---|
| A proposal changes nothing until the operator answers, and asks at the level approve | the module's test: the proposal's ask is published under `ask.claude-code`, passes the SDK's `Check`, offers Approve and Decline both at the level approve, each binding an act digest; the open ask leaves the rendered instruction file without the text |
| The text becomes active on a node only on a warrant that node proved | the module's test: an approval in the router's record makes a second node render the section; a decline, an expiry, the router's refusal, a text changed after the ask (with or without its digest, and past what the phone shows), another ask's words under the module's name, an answer after expiry, at the level acknowledge, with nobody choosing, or for another ask each render nothing and are recorded as decided |
| A warrant is acted on once | the module's test: the same warrant read again changes nothing and is recorded once; an approval replayed into the bus after its section was removed brings nothing back |
| A removal needs the same approval; a deleted record removes nothing | the module's test: deleting the proposal record leaves the section rendered; proposing the removal leaves it; the approved removal takes it out |
| A node scope reaches only its nodes | the module's test: a section approved for one node renders there and not on another |
| Fail closed when no channel proves its sender, or the bus refuses the ask | the module's test: with no verified-sender channel the proposal is refused naming the terminal's line, and nothing is kept or asked; an ask the bus refuses leaves the proposal never asked and never active |
| What a reader cannot see, or the file would read in, is refused | the module's test: an `@` after a vertical tab, a no-break space, an ideographic space, a line separator or a bracket; tag characters, a zero-width space, a bidirectional override, an escape, a carriage return, private use, and headings of level one to three are each refused; an e-mail-like word and a fourth-level heading are taken |
| A record written straight into the bus is judged before its approval applies | the module's test: a record with an import, a heading in its name, its nodes out of order, or too long, asked for under the module's name and approved, is refused and renders nothing |
| An older approval never undoes a later one | the module's test: a node that reads a removal before the addition it removes leaves the addition superseded and the section absent |
| An unreadable record is never taken for an empty one | the module's test: over an unreadable record nothing is decided, proposed or written |
| A section for some nodes is its own | the module's test: the same name for other nodes is an addition, and a removal for them is refused |
| Issue 340's refusals hold | the module's test: a shell line, a shell block, front matter (with `hooks` or `allowed-tools`, or any), an `@` reference, a key block, a value given to a token's name, more than 16 KiB, a NUL, the home scope, `all` at the node scope and a name the terminal sets are each refused and nothing is asked |
| A long text is shown in part and read whole at the terminal | the module's test: a line with a path is not on the phone, the explanation names the terminal's line and the fingerprint, and `claude_code_proposals` with the id answers the whole text and digest |
| The text is never logged | the module's test: the module's log over a proposal and its approval holds the id and not a line of the text |
| The tools are served | the module's test through its real tool dispatch: propose, list, and the registering tool's refusal naming the proposal tool |
| The warrant is recorded once, from the router's record | the controller's tests: the entry carries who chose, via which channel and the proofs from the router's warrant; an ask still open, ended without a choice, another asker's or another ask's, or the controller's own is refused; the entry is written once however often it is asked, against a real bus; a word of the caller's own is refused; the verb's line is not taken for a repair and is not the terminal's alone |
| Live, after rollout | an agent proposes a hand-over for every node; the operator's phone shows "Add instructions '…' to every Claude session on the mesh?" with Approve and Decline; after Approve a new session on another node reads the text, and `hand-acts` shows the warrant |
## References
- [Issue 340](../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md) — the
decision this amends
- [ADR 0259](0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md) — the ask
and the warrant
- [ADR 0273](0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md)
— the module owns the agent's home; the operator's rules on a warrant
- [ADR 0216](0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)
— the three scopes
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §11 — the design
@@ -0,0 +1,167 @@
---
topic: how we work
status: accepted
date: 2026-10-10
deciders: jochen
reconstructed: false
---
# 275. 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
## Context
[Research 042](../01-RESEARCH/042-the-mesh-keeps-its-own-issues/00-overview.md) measured, on 2026-10-10, how this
repository keeps its issues, after the operator asked that day for an issue tracker on the bus that every current issue
is consolidated into, started small and extended along the way.
**Numbers collide.** An author takes the next free number by reading `main` and every open pull request
([playbook 03](../00-META/process/03-issues.md)), and many branches write at once. 31 commits on `main` mention a
renumbering; [issue 155](../04-ISSUES/155-two-records-may-share-a-number-and-nothing-says-so/00-report.md) records a
collision that reached `main` with every check passing; three decision numbers collided on the evening of 2026-10-07,
and on 2026-10-10 an open pull request still carried a branch named for an issue number another branch had taken
first. `cycle.py` refuses two records sharing a number, which catches a collision after it happens and prevents none.
The operator said on 2026-10-07 that numbering belongs to the mesh's own issue tracker.
**Status is hard to see.** Of 357 issue folders on `main`, 73 were not closed (27 `open`, 45 `located`, 1
`diagnosing`), and finding them means reading frontmatter across the repository. The frontmatter is a small, regular
data set: six keys over 1.9 MiB of markdown, well inside the limits
[ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)
§6 sets for a bucket. 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.
**hits**, the headless issue tracker the operator named as inspiration, was studied at release v0.7.0 (findings §6). Its
model is this repository's frontmatter almost key for key, and it numbers an issue by compare-and-set on a counter only
after the issue is validated. Its licence (Sustainable Use License 1.0) limits distribution, its services create their
own bus resources on start, and its words (*item*, *project*, *release*) clash with the glossary.
The research left five questions for the operator, each with a recommendation. **The operator delegated the choice on
2026-10-10**: the recommendations are taken, in the spirit of the original ask, "start small and extend the module
along the way".
### Against GENESIS
- **The mesh owns the truth** ([mission](../00-META/mission.md)). An issue's number and status live in the mesh, in one
place, written through one module, instead of in a file every branch may edit.
- **The mesh remembers** ([effect](../00-META/effect.md)). The public narrative of a design or governance issue stays
here, reviewed in pull requests, so the symptom-to-component memory is kept.
- **Failure must be loud.** The cycle's issue rules refuse a wrong write at the moment it is made, instead of a check
reporting it at merge.
## Considered Options
### Where the truth lives (open question 1)
1. **Split: the tracker holds every issue's number and status; hq keeps a folder only for a design or governance issue,
pointing at the tracker by number.** Adopted.
2. **Everything moves into the tracker.** Rejected: the public record of design questions, which hq exists to be
([ADR 0019](0019-how-this-repository-works.md)), would be lost, and with it the memory playbook 03 says is never
deleted.
3. **The tracker mirrors hq.** Rejected: hq would still hand out numbers, so the collisions stay, and a second copy of
every status can drift from the first.
### Whose code (open question 2)
1. **Run hits as it is.** Rejected: its services create their own stream and bucket, which ADR 0201 §3 forbids a module
to do; its subjects assume one tracker per bus account; its actor is named by the caller rather than proven by the
bus credential; its words clash with the glossary.
2. **Vendor or fork hits into the catalogue.** Rejected: its licence is not an open-source licence, and vendoring binds
the catalogue to it for as long as the code is there; changing the points above means owning a fork of a young,
single-author project.
3. **Build our own module in Go on hits's model.** Adopted. Its statuses, fields, append-only notes, typed links,
compare-and-set numbering and its auditor are copied as ideas, and the module's README says so.
### Which numbers (open question 3)
1. **Issue numbers only.** Rejected: decision numbers collided three times in one evening, and the same counter serves
any sequence.
2. **Issue, decision and research numbers, all from the tracker.** Adopted, with decision and research numbers in
slice 2.
### The word (open question 4)
1. **Adopt the operator's word *ticket*.** Rejected: *issue* is already the glossary's word for this thing, and one word
names one thing ([ADR 0244](0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)). *Ticket*
also already names something else: an answer's one-time token
([ADR 0258](0258-a-desk-click-performs-only-an-acknowledgement-and-an-answer-is-a-one-time-ticket.md)).
2. **Adopt hits's word *item*.** Rejected for the same reason.
3. **Keep *issue*; call the module that holds them the *issue tracker*, named `issues`.** Adopted.
### Conditions (open question 5)
1. **The controller opens an issue by itself for every `healer-wanted` condition, from the start.** Rejected for now:
an automatic opener with no week of linked data behind it can flood the tracker with issues nobody reads.
2. **The controller only links issues to conditions until slice 4 has run for a week; then it opens one issue per
`healer-wanted` condition key by itself.** Adopted.
## Decision
1. **The issue tracker is a Go module, `issues`, with one instance on the control-node.** It declares and claims one
seat held once for the mesh, `issue-tracker`
([ADR 0126](0126-a-module-declares-its-own-seats.md)). It is built on hits's model and carries none of its code.
2. **The tracker is the truth for every issue's number, status and links.** Every issue is opened in the tracker and
takes its number from it, including one that needs no document here. A design or governance issue also gets a
folder `04-ISSUES/NNN-slug/` under the tracker's number, whose narrative is reviewed in pull requests as today.
Status lives in one place.
3. **A number comes from one counter, advanced by compare-and-set, and only after the issue is valid.** A refused
open spends no number, and two callers opening at the same moment never get the same one. The runtime's state
verbs gain a write that is refused when the key changed since it was read; the counter does not rely on there
being one instance. The counter starts above the highest number on `main` and on every open branch at the moment of
the switch. A number never moves again, and numbers already skipped stay unused.
4. **State is in buckets the module declares** (ADR 0201): the controller creates them, the module never does.
**Every change is an event the module declares** ([ADR 0041](0041-events-are-a-relationship.md)), carrying what
changed and who changed it, so the events are the audit trail.
5. **The caller is the bus credential, never an argument.** The module records who acted from the identity the
runtime gives it with each call.
6. **The cycle's issue rules are enforced at the write.** A status outside the five is refused; `located` and
`resolved` need `located-in`; `resolved` needs `fixed-by`; a core issue resolves with `replay` or `replay-none`
([ADR 0237](0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md));
`wontfix` needs a reason. `located-in` names an entry of a registered list of repositories and modules.
7. **The word is *issue*.** The module is the *issue tracker*, named `issues`; *ticketing system* is retired in the
glossary, and the bare word *ticket* is a homonym that never means an issue.
8. **It is built in slices, each ending at something the operator sees.** Slice 1 is the module with `open`, `get`,
`list`, `move`, `note` and `link`, and playbook 03 takes an issue's number from it. Slice 2 hands out decision and research numbers too. Slice 3 imports the issue
folders and consolidates them, after which one pull request removes the status keys from every report and changes
playbook 03 and `cycle.py` together. Slice 4 links conditions and pull requests. Slice 5 is a read-only view onto
the tracker's bucket. The design is [to-be 53](../03-DESIGN/01-to-be/53-the-issue-tracker.md); once slice 1 runs,
each slice after it is an issue in the tracker itself.
9. **Conditions are linked, not opened, until slice 4 has run for a week.** A condition is never closed by closing an
issue: a condition is cleared only by observation
([ADR 0231](0231-a-healer-acts-on-what-observation-raised-and-only-observation-says-it-worked.md)).
> **Progressive insight — 2026-10-10.** Two facts, found as slice 1 was built. **The module's name:** §1 and §7 name
> the module `issues`. The operator named it `mesh-issues` on 2026-10-10, as the mesh's own parts carry the `mesh-`
> prefix (`mesh-delivery`, `mesh-vault`). The seat stays `issue-tracker`, the role, as `operator-channel` is held by
> `messenger`; the thing is still the *issue tracker*, and its verbs are still called `issue-tracker.<verb>`.
> Wherever this record says the module `issues`, read `mesh-issues`; the state named `issues` keeps its name.
> **Compare-and-set:** §3 says the runtime's state verbs "gain" a write refused when the key changed. They had it
> already: `create` and `update` at a revision, with a lost write told by its own error code, came with
> [ADR 0259](0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md).
> What slice 1 added is the SDK's `Increment`, which reads a counter and writes the next number only at the revision
> it read. The decision stands as written.
## Consequences
- Collisions end at the source: a number is taken from one counter, not read from a repository many branches write.
- Until the slice 3 import has run and been checked against it, an issue that has a folder here keeps its status in
that folder's frontmatter, and the tracker holds its number, title and folder only. An issue without a folder is
whole in the tracker from the start. No status is written in two places for the same issue at any time.
- [ADR 0019](0019-how-this-repository-works.md)'s rule that statuses live only in frontmatter stops holding for issues
on the day slice 3's pull request lands, and part of [ADR 0080](0080-the-development-cycle-is-checked.md)'s check
moves from `cycle.py` into the tracker's `move`. That pull request adds a dated note to each of them saying where
the mechanism now lives; until then both stand unchanged.
- An agent opening an issue needs the mesh to be reachable. A node that cannot reach the control-node cannot take a
number, and says so, rather than guessing one.
- The SDK's state interface gains a compare-and-set write, which every module keeping a counter can use; it is a
contract change under [ADR 0039](0039-what-the-sdk-holds-and-refuses.md), and every module should be rebuilt
against it.
- What got harder: one more module the mesh's own process depends on. hq's checks today run from a checkout alone;
from slice 2 on, writing a decision or an issue here needs the tracker.
## References
- [Research 042](../01-RESEARCH/042-the-mesh-keeps-its-own-issues/00-overview.md) and its
[findings](../01-RESEARCH/042-the-mesh-keeps-its-own-issues/01-findings.md).
- [To-be 53 — The issue tracker](../03-DESIGN/01-to-be/53-the-issue-tracker.md).
- hits, `github.com/impire-io/hits`, release v0.7.0: the model studied, not the code used.
- [Issue 155](../04-ISSUES/155-two-records-may-share-a-number-and-nothing-says-so/00-report.md): the collision that
reached `main`.
@@ -0,0 +1,218 @@
---
topic: the mesh
status: accepted
date: 2026-10-10
deciders: jochen
reconstructed: false
supersedes-in-part:
- 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/0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md
- 02-DECISIONS/0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md
extends: 02-DECISIONS/0249-a-delivery-groups-order-rules-have-a-precedence-and-a-declared-order-outranks-them-all.md
---
# 276. Merges are assembled in a rolling window, and each batch is delivered by one walk with one commit per repository
## Context
Shortly after midnight on 2026-10-10 two pull requests of the catalogue were merged 18 seconds apart, the
claude-code change at 00:20:52 and the dunst change at 00:21:09
([issue 362](../04-ISSUES/362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person/00-report.md)).
The controller opened one walk for each merge, as [ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
decided ("a merge produces a tiered plan"). The second walk superseded the first, as
[ADR 0218](0218-a-plan-sends-grants-before-code-rolls-out-one-machine-first-and-a-newer-merge-takes-over-an-older-plan.md)
decision 3 decided, and both were planned at the dunst commit, as the fix for
[issue 349](../04-ISSUES/349-a-merge-acted-on-late-superseded-the-merge-after-it-and-built-the-older-commit/00-report.md)
decided. mesh-delivery then tied the surviving walk to the dunst delivery, which the supersession had already
ended, and tied no walk to the claude-code delivery. Nothing gave the walk its word. The change waited 58
minutes, until the operator started the walk by hand with `plans go`.
The diagnosis found two parts that each behave as written and disagree on what a walk's commit means. That
is the narrow cause. The wide one is that the mesh plans one walk per merge, while merges arrive in bursts:
- **A burst is the normal case.** The operator merges a batch of reviewed pull requests at once, often
across repositories (a catalogue change with the controller change it needs). Every merge opened a walk,
and every walk after the first superseded the one before. Three supersession paths now exist (ADR 0218,
issue 254, issue 349), each with its own bookkeeping, and issue 362 is a fourth way for them to disagree.
- **A walk superseded mid-gate wastes the gate and confuses its record.** A first machine judged healthy
for a build that the next walk replaces a minute later proves nothing about the build that stays. On
2026-10-09 a controller merge landing while another walk was at its gate rolled everything back; the rule
"one controller rollout at a time" was kept since then by the person merging, not by the mesh.
- **ADR 0239 made a delivery "one commit … from commit to delivered" and walked it alone**, while its own
decision 4 delivers a delivery group across repositories as one unit. The walk the commit gets is
decided by when the controller heard its merge, which is not something the person merging controls.
On 2026-10-10 the operator decided the model, in their words:
> "a delivery doesn't really 'run from commit to delivered', we've already discussed that a delivery plan
> might start with a set of commits spanning multiple repo's, however, only 1 commit per repo is allowed
> … we assemble all commits that are coming in with a 1 minute rolling window, so if a new commit comes
> in, less then 1 or 2 minutes after the last one, the builder again waits 1 or 2 minutes, until no new
> commit came in, then it looks at the 'group of commits' and decides if they all fit into 1 delivery
> plan … This is a hard problem to tackle, but it will improve our build setup I believe."
> "the 'actively assembling plan' is very interesting, the controller can say 'Still waiting for 13
> seconds for new commits, these are the currently grouped commits, plan is yet to be calculated'."
On two commits of one repository in one window, the operator chose the later commit over splitting:
"i like your idea better, I just thought it would be more complex, but if it's reasonably doable, go for
it".
**Checked against GENESIS.** *Failure must be loud*: an assembling batch and a waiting walk each have a
bound and a condition. *The mesh owns the truth*: the window, the batch and the walk are kept by the
controller and shown by its `plans`, not inferred from timing by a person. Nothing here conflicts with
GENESIS.
**Words.** The glossary had no word for the merges one window collects, nor for the window. This record
adds **merge window** and **batch** to it (Change and delivery), with this record. *Group* is not used for
either, because **delivery group** already names deliveries that share a head branch name.
## Considered Options
**What a walk answers.**
1. **One walk per merge, as today, with the bookkeeping repaired.** The walk names the merges it answers,
and mesh-delivery ties a superseding walk to every live delivery it contains. Rejected as the model,
kept as a repair (decision 8): it makes the 362 shape deliver, but every burst still opens and
supersedes walks, a walk can still be superseded at its gate, and a cross-repository change is still
walked as separate walks in whatever order the merges were heard.
2. **A rolling merge window, and one walk per batch** (chosen). Merges are collected while they keep
arriving; the batch they form is planned once, as one walk with at most one commit per repository.
3. **Every walk started by hand.** The operator named it as the alternative ("this does mean we need to
manually start all delivery plans, or, even better, …"). Rejected: a person in every delivery is the
wait ADR 0239 exists to remove, and a merge that passed its checks must reach its machines without a
person.
**Two commits of one repository in one batch.**
1. **Split into two walks in merge order** (the operator's first proposal). The second walk waits for the
first. Rejected: every machine running the module builds, is sent and is judged on the earlier commit,
and then again on the later one minutes after; the gate judges a build that is about to be replaced;
and the batch's walk stops being one unit.
2. **One walk with the later commit** (chosen). On a linear trunk the later merge commit contains the
earlier one, so building it delivers both. One build, one gate, one rollout. The earlier merge is
answered by its successor and ends `covered` when the walk is delivered. Only when that walk fails are
the earlier merges walked on their own, to find which one brought the failure.
**Who keeps the window.**
1. **mesh-delivery**, which owns deliveries (ADR 0239). Rejected: a walk on the controller's own path (the
controller, the node-engine, the node tools, the bus, mesh-delivery itself) must not wait for
mesh-delivery, and with no holder of the seat on record the controller plans alone. The window would
then exist twice, once in each.
2. **The controller** (chosen). It hears every merge (the bus, and the catch-up that reads back what the
bus missed), it owns the planner and the walk, and it already plans alone for its own path. mesh-delivery
keeps what it owns: each commit's delivery record, the order of a delivery group, and the word that lets
a waiting walk start.
## Decision
1. **A rolling merge window.** The controller collects every merge into a branch a module follows into the
open **batch**. The window is rolling: each merge restarts it. It closes when no merge arrived for the
window's length, 90 seconds by default (the controller's setting `merge-window`), and at the latest 10
minutes after the batch's first merge (`merge-window-at-most`), so a busy period never starves delivery.
The batch, its merges and its times are kept in the controller's store, so a restarted controller
resumes the window where it stood rather than losing or doubling a merge.
2. **A batch is planned once, as one walk with at most one commit per repository.** When the window closes,
the planner computes one delivery plan for the batch: every module its commits move, their dependents,
in tiers along the module graph, across repositories. For a repository with two or more merges in the
batch the walk takes the latest commit of its branch, which contains the earlier ones. **Every merge
belongs to exactly one walk**: the walk names every merge it answers, its own commits and the earlier
ones they contain. A merge is never dropped and never walked twice.
3. **The earlier merge of a repository is answered by its successor.** Its delivery follows the walk and
ends `covered` when the walk is delivered, naming the merge that carried it (the glossary's word,
[ADR 0270](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);
here the containment is the trunk's order inside one batch, so the forge is not asked). **When that walk
fails**, the walk's own commits fail, and each earlier merge it answered is walked on its own commit, the
newest first, one walk each, in the next batches: the first that is delivered ends `delivered`, and the
search stops there. A walk that failed never marks a merge delivered.
4. **One walk at a time, and nothing supersedes a walk that started.** A batch becomes a walk only when no
walk is open. Merges that arrive while a walk runs join the next batch, whatever its window says. A walk
that has not started, because it waits for mesh-delivery's word, is folded into the next batch when that
batch is planned, and the new walk answers every merge the folded one answered. This is the only
supersession left; ADR 0218 decision 3 and the issue 349 rule of planning a late merge at a later commit
are replaced by it. A merge heard after a later merge of its repository and branch was put in a walk is
answered by that walk: by the batch still assembling, by the open walk, or, when that walk was delivered,
it ends `covered` at once.
5. **The order of a delivery group holds inside a walk** (ADR 0249 unchanged). The pairs its order rules
give (declared, built by, version skew, engine before controller, each with the rule that won) become
edges of the walk's tiers beside the module graph: the modules of a member ordered after another are in
a later tier than that member's. A contradiction still rejects the group before anything merges.
mesh-delivery gives a walk its word only when every delivery it carries is at its turn.
6. **A delivery is one commit's record, and its walk is the batch's.** A delivery keeps its pull request,
its status, its note and its check, as ADR 0239 decided. From its merge on it is delivered by the walk
that answers it, and every delivery a walk carries ends with that walk's result: `delivered` (its own
commit) or `covered` (an earlier commit of the same repository) when the walk is done, `failed` when it
fails, `stopped` when it is stopped. The walk's machine steps are recorded on each delivery it carries.
7. **The assembling state is shown.** The controller's `plans` lists the open batch first, as the operator
asked: "assembling: 13 s left (at the latest 00:31:09); grouped: novox/mesh-catalog@48bda475 (answers
553b7191), novox/mesh-controller@a6bc0931; plan not yet calculated". It says the batch on the bus as
`plan-moved` with the state `assembling`, so mesh-delivery's `deliveries` and `show` say of a merged
delivery that its batch is assembling and how long is left, and of one whose batch waits that it waits
behind which walk.
8. **The bookkeeping is repaired as well** (the narrow cause of issue 362), because decision 4 keeps one
supersession path. A walk names every merge it answers, and a superseded walk names the walk that took it
over. mesh-delivery ties a walk to every live delivery whose merge it answers, follows a walk to the one
that took it over, and **never ties a walk to a delivery that has ended**. A walk waiting for the word
that no live delivery holds is either adopted as a delivery of its own or listed at once by `stalled`.
9. **Every wait has a bound and a condition.** A batch still assembling a minute past
`merge-window-at-most` while no walk is open raises a condition: the controller failed to cut it. A
batch waiting behind an open walk longer than that walk's bound raises one naming the walk. A walk
waiting for its word past 30 minutes is S16's, as before, and its words name the merges it answers. A
merged delivery no walk answers within ten minutes is held with the true reason (which open walks of its
repository exist and that none names its merge, or that the controller holds none) and listed by
`stalled` at once, so probe D14 raises it for the operator.
## Consequences
- **A merge waits up to 90 seconds before it is planned**, and up to 10 minutes in a busy period. A walk
takes minutes to build and longer to judge, so the wait is small beside it, and it removes the builds,
sends and gates that a superseded walk wasted.
- **A cross-repository change merged within a window is walked as one unit**, tiered by the graph and its
group's order. A person no longer has to merge in order and wait in between.
- **One walk at a time is mechanical.** A controller merge can no longer land in the middle of another
walk's gate; it waits for the next batch.
- **A failing walk fails every delivery it carried**, including one whose own modules passed before the
failure. Each delivery's machine steps say how far it got, and decision 3's search finds the culprit only
among the merges of one repository. A failure in a walk that carried several repositories is the
operator's to read. This is the price of one unit, and the operator chose it.
- **What got harder**: the controller's merge handling holds state across merges (the open batch) and is
restart-safe; the walk's record names several repositories and commits instead of one, and every reader of
it (`plans`, S3, S16, `plan-moved`, mesh-delivery) changes with it. The store gains the batch and the
merges a walk answers, as a numbered migration and in the consolidated schema.
- **The replay of issue 362** in the lab raises a mesh, merges two commits of one repository 18 seconds
apart, and passes when one walk delivers both without a person; it fails on the commit before the change.
## How it is checked
- The controller's tests: two merges of one repository 18 seconds apart make one batch and one walk at the
later commit, answering both; merges in three repositories within the window make one walk; a merge at
second 80 restarts the window; the window closes at `merge-window-at-most` however busy; a merge arriving
while a walk runs joins the next batch; a walk that has not started is folded into the next one; `plans`
shows the assembling state; the batch survives a restart; the two conditions of decision 9 are raised past
their bounds and not before.
- mesh-delivery's tests: both deliveries of one walk end `delivered` and `covered` with its done record; a
failed walk marks none delivered and walks the earlier merge alone; a walk is never tied to an ended
delivery; a walk is given its word only when every delivery it carries is at its turn; a group's order is
unchanged; a held delivery with no walk names the true reason and is listed by `stalled`.
- Each of those tests fails with its part of the change removed.
- mesh-lab's replay R362 (decision's consequence above), proved to fail before the change and pass after it.
## References
- [Issue 362](../04-ISSUES/362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person/00-report.md),
its diagnosis, and the replay R362 in mesh-lab.
- [ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md) (a merge produces a plan),
[ADR 0218](0218-a-plan-sends-grants-before-code-rolls-out-one-machine-first-and-a-newer-merge-takes-over-an-older-plan.md)
decision 3 (a newer merge takes over an older plan), [ADR 0238](0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md)
decision 2 (one commit, one change plan), [ADR 0239](0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md)
(a delivery walked by its own commit's walk), each superseded in the part this record names.
- [ADR 0249](0249-a-delivery-groups-order-rules-have-a-precedence-and-a-declared-order-outranks-them-all.md),
whose order rules this record keeps and uses as tier edges.
- [ADR 0270](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),
whose `covered` ends an earlier merge of a repository.
- [Issue 349](../04-ISSUES/349-a-merge-acted-on-late-superseded-the-merge-after-it-and-built-the-older-commit/00-report.md),
whose rule decision 4 replaces.
- To-be [47](../03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md) and
[10](../03-DESIGN/01-to-be/10-delivery.md), amended with this record.
+6 -5
View File
@@ -223,8 +223,9 @@ The same path, with the mesh built in the middle of it.
# 1 — raise the foundation and the controller from the carried bundle
nox-mesh-host reconcile
# 2 — the controller now exists, and issues the first token
mesh-controller token issue
# 2 — the controller now exists, and issues the first token (nox, the operator's command,
# at the controller's terminal: ADR 0272)
nox token issue
# 3 — the machine joins the mesh it just raised
nox-mesh-host enrol --token <token>
@@ -618,8 +619,8 @@ rather than left to be worked out.
**A token is issued *for* a node record**, and that is where a re-enrolment is decided.
```
mesh-controller token issue --node workstation # this machine is that node again
mesh-controller token issue --new # a machine the mesh has not seen
nox token issue --node workstation # this machine is that node again
nox token issue --new # a machine the mesh has not seen
```
The node-engine does not need to know which it is. It presents a token and receives an identity; what
@@ -699,7 +700,7 @@ keeps cataloguing.
### Where the enrolment token comes from
**`mesh-controller token issue` prints it once**, to the person running it. Single-use, and it
**`nox token issue` prints it once**, to the person running it at the controller's terminal. Single-use, and it
expires whether used or not ([ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)).
It is carried by hand — read off a screen, pasted into a terminal. That is the design rather than
+8 -1
View File
@@ -5,8 +5,9 @@ code:
- mesh-controller internal/builder
- mesh-controller cmd/mesh-controller (build, build --behind, push, status)
- mesh-controller internal/inventory/builds.go
updated: 2026-10-06
updated: 2026-10-10
decisions:
- 02-DECISIONS/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md
- 02-DECISIONS/0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md
- 02-DECISIONS/0090-a-failure-that-repeats-is-said-to-be-stuck.md
- 02-DECISIONS/0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md
@@ -282,3 +283,9 @@ everything else while that module is held. A person can always deliver by hand.
The controller keeps the comparison, the planner, the gate, sending and rollback. `mesh-delivery` keeps the
record and the order, and asks.
**Merges are walked in batches** (2026-10-10, by [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)). The controller collects
merges in a rolling merge window, 90 seconds by default and at most 10 minutes, and plans each batch once, as
one walk with at most one commit per repository: the latest of its branch, which contains the earlier ones.
One walk is open at a time, and a walk that started is never superseded. `plans` shows a batch while it
assembles: what it holds and how long is left. To-be 47 says how a delivery follows its batch's walk.
@@ -2,8 +2,10 @@
layer: to-be
status: in-progress
code: [mesh-catalog modules/claude-code]
updated: 2026-10-09
updated: 2026-10-10
decisions:
- 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
- 02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md
- 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/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
@@ -63,12 +65,21 @@ instruction file and the manager's tools:
**The home.** Under [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)
the module owns the directory `~/.claude` — that it exists, that the operator owns it, its mode,
`0700` — and declares it, so the mesh refuses a second module owning it. Of what is inside, it owns only
the agent's credentials file, which its own code writes for a subscription licence (§5); every other path
is *found*. The person's memory, history, projects, local
settings, their own rules, skills and tool servers are never read or written by the mesh. **The six
predecessor files are the operator's to remove, once, on each workstation**; the module's documentation
lists them, and until they go the agent reads stale instructions beside the mesh's.
`0700` — and declares it, so the mesh refuses a second module owning it.
> **Revised 2026-10-09 by [ADR 0273](../../02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md).**
> This paragraph said the module owns only the credentials file inside the directory and holds every other
> path as found, the person's own rules and skills included. Now **every configuration file under the
> directory is the module's** — the home instruction file, `rules/`, `skills/`, `commands/`, `agents/`,
> `hooks/` and `settings.json` — rendered from registrations and never written by hand; and **the program's
> working state is excluded by name** and stays found: the credentials file (written by the module's own
> code, §5), the history, the sessions, the projects and their memory, the caches, the program's own plugin
> records, the vendor's synced skills, and the local settings file the program writes with a person's
> permission answers. §10 says how, and what a hand edit becomes. The six predecessor files are no longer
> the operator's to remove by hand: a file nobody rendered is a condition, and the operator imports or
> removes it through the module's tools.
The person's memory, history and projects are never read or written by the mesh.
## 2. What the module declares and what its code writes
@@ -244,28 +255,37 @@ machine. A machine that joins later takes it at its first start.
The managed settings name the marketplace and enable the plugin. Those two keys are the mesh's, laid
last with the attribution key, and no setting replaces them.
**Three scopes.** Every registration names one:
**Three scopes, and a fourth since 2026-10-09.** Every registration names one:
- **mesh:** every machine running the agent;
- **node:** one machine or a list of them, rendered into the same plugin and files there only;
- **home:** the operator account's own agent directory on one machine, where the item sits as if
written there by hand.
- **home:** the agent account's own agent directory on one machine, where the item sits as if
written there by hand. Since [issue 340](../../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md)
it is taken only for the node serving the call, and only where that home is an agent account's own
(§9), never the operator's;
- **operator** (ADR 0273, §10): the operator's own items, rendered into the agent's home on every node that
names an operator account — the operator's own home where the agent runs as the operator, the agent
account's home where the node names one.
Settings take the first two scopes only. In the managed settings file they are laid in this order:
the operator's `managed_settings` setting (§2), then the mesh scope, then the node scope, then the
mesh's own keys.
Settings take the mesh and node scopes into the managed settings file, laid in this order: the operator's
`managed_settings` setting (§2), then the mesh scope, then the node scope, then the mesh's own keys. The
operator's own settings take the operator scope, into the home's `settings.json` (§10). **Since issue 340,
hooks, permission rules, tool servers and every kind of shared text for the mesh or a node are set at the
controller's terminal alone**, in the module's settings; the tools refuse them and say so.
**Instructions follow the scopes.** The managed instruction file holds the mesh's piece, then the node's
piece: its role, and the sections registered for it. Further customisation per machine is a rule file
placed at the home scope. The agent concatenates these and does not override, so the status tool names
a section that contradicts another, or that calls a tool the mesh no longer serves.
**The home scope owns only what it placed.** The module records each home path it placed and touches
only those (ADR 0182). It refuses to register a name the person already uses there.
**The home is the module's** (ADR 0273, replacing "the home scope owns only what it placed"). The module
renders every configuration file under the agent's home from the registrations that apply there, at the
operator and home scopes, and records what it rendered. A name registered twice is refused: an operator-scope
item is rendered first, and a home-scope item may not take its name.
The one exception is a removal the person asks for ([ADR 0243](../../02-DECISIONS/0243-the-agent-module-removes-a-home-item-it-did-not-place-only-on-the-persons-word-and-keeps-a-copy.md)).
On the person's word, with a reason, the module removes an item it did not place. It keeps a checked copy in its own
state and logs the removal, and can put the item back.
A removal the person asks for ([ADR 0243](../../02-DECISIONS/0243-the-agent-module-removes-a-home-item-it-did-not-place-only-on-the-persons-word-and-keeps-a-copy.md))
is now the operator's answer to a file nobody rendered (§10). On the person's word, with a reason, the
module removes it, keeps a checked copy in its own state, logs the removal, and can put the item back.
**The tools.**
@@ -281,7 +301,7 @@ An item and its files are one value in the state, refused above 256 KiB.
**Changing the agent's own settings is the operator's act.** The vendor refuses an agent that loosens its
own settings, and the module does not route around that refusal. A settings tool is called on the
operator's word.
operator's word — and, for the operator's own home, only at the terminal or on a warrant (§10).
## 9. The account the agent runs as
@@ -363,6 +383,143 @@ terminal. The next send names the operator account again: the guard lets the ope
and the licence is fetched into the operator's home. The agent account is kept (the node-engine never deletes
a login).
## 10. The home is the module's: personal rules, and the lessons an agent proposes
*Added 2026-10-09 by [ADR 0273](../../02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md).*
**What the module renders under the agent's home**, on every node, as the account the agent runs as (through
the helper of §9 where that is an agent account):
| path | rendered from |
|---|---|
| the home instruction file | the index of the operator's personal rules: each rule's name, where it came from (the terminal, or the lesson it was accepted from) and when; a first line saying the file is the module's and names the tools that change it |
| `rules/<name>.md` | one personal rule each |
| `skills/<name>/`, `commands/<name>.md`, `agents/<name>.md` | the operator's own skills, commands and subagents at the operator scope, under their own names; and the home-scope items where the home is an agent account's own |
| `hooks/` | nothing: a hook is the terminal's, in the managed settings. The directory is kept empty |
| `output-styles/<name>.md`, `keybindings.json` | the operator's output styles and keybindings at the operator scope |
| `settings.json` | the operator's registered settings, preference keys only, with the keys the program itself writes on a person's choice (the model, the spinner, the drafts, the other preferences the vendor's own commands set) taken over into the registration when the program changes them, so a choice made in one session follows the operator. A key that decides what runs (`hooks`, `permissions`, `mcpServers`, `env`, `apiKeyHelper`, `statusLine`, `autoMode`, any key not on the list) is never taken over: changed on disk, it is written back on the look that finds it. `autoMode` is a permission rule, so it lives in the managed settings at the terminal |
"Completely empty", the operator's words, means: nothing in the home was put there by hand. What is not the
module's rendering is the program's own working state, excluded by name in the module's documentation and
never written or removed: the credentials file (§5), the history, the sessions and their environments, the
projects and their memory, `todos/`, `plans/`, `statsig/`, the shell snapshots, the file history, the debug,
cache, paste and telemetry directories, the statistics, the daemon's files (`daemon-auth-cooldown` among
them), the dot-markers (`.last-cleanup`), `skills/synced/`, and the backups, worktrees, jobs, feedback, state
and IDE directories. **Two are the program's and watched**: `settings.local.json` (the permission answers a
person gives) and `plugins/` (marketplaces and installed plugins). **Named as neither**: `.hal-binding.json`,
a predecessor's leftover, and every copy of the credentials file (`.credentials.json` followed by anything,
such as `.credentials.json.testbak`): reported, and removed on the operator's word, no copy kept because it
holds a token. **Any other entry is a condition** until the operator removes it or the module names it.
**Who may register into the operator's home.** A registration at the operator scope, or at the home scope
into a home that is the operator's own, is taken by two routes and refused through any other:
1. **at 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)):
the module's settings `personal_rules`, `personal_skills`, `personal_commands`, `personal_agents`,
`personal_output_styles`, `personal_keybindings`, `personal_settings`, `lessons_accepted` and
`lessons_declined`, set with `nox settings set claude-code …`. The module's whole layer is already the
terminal's (issue 340);
2. **on a warrant** ([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 module uses the `operator-channel` seat as an asker. A register, import or remove tool called for the
operator's home acts on nothing: it publishes an approve ask showing the item in full, its digest binding
the text, name, kind and scope, and acts once on the warrant for that ask.
**The tools stay the operator's way in.** While no channel answers approve for the module, such a tool
records the request as **pending** in the module's state and answers with the exact line to type,
`nox settings set claude-code '<json>'`, the request merged into the current layer so that nothing else
changes. The status tool lists pending requests; one is dropped once the layer holds it, or after 7 days.
Nothing that runs — a hook, a permission rule (`autoMode` included), a tool server, an environment or
key-helper setting, a skill or command holding a shell line or the front matter issue 340 refuses — is taken
at the operator scope by either route. Issue 340's protection stands: a tool call without a warrant writes
nothing into the operator's home. A home-scope name never shadows an operator-scope item of the same kind.
**A hand edit is a condition, undone on the look that finds it.** At least once a minute, and at once on a
change on disk, the module compares the rendered paths with the disk and reads the two watched entries. Any
difference is said in its health as a finding on that node, and the controller raises **`agent-home-drift`**,
a warning that needs the operator, naming each path. That look acts:
- a rendered file changed by hand (`settings.json` above all) is written back, the hand version kept in
`home-copies` and shown by the status tool; only a preference key the program changed is taken over;
- a rendered file missing is rendered again;
- a file nobody rendered under `rules/`, `skills/`, `commands/`, `agents/`, `hooks/` or `output-styles/` is,
**before the node's first adoption** (the imports below, recorded once per node), left and reported for the
operator to import or remove (ADR 0243); **after it**, moved into `home-copies`, restorable, and raised;
- an entry under neither list, a key other than `permissions` in `settings.local.json`, and a marketplace or
plugin in `plugins/` other than the vendor's and `nox-mesh` are raised, naming each, and left.
The condition clears on the first clean look; the first-node gate never reads it as a fault of a build
([ADR 0254](../../02-DECISIONS/0254-a-wait-for-a-persons-new-login-is-not-a-gates-failure-and-each-module-of-a-send-keeps-its-own-pass.md)'s
exemption). **The guard** (§3) refuses, naming the module's tool and saying a rule the agent wants is a
lesson: any shell command or file edit that writes, appends to, copies or moves into, edits in place or
removes a rendered path, `settings.local.json` or a `.json` file under `plugins/`; and `claude plugin
install`, `claude plugin marketplace add` and their in-session forms. It does not watch the rest of the
working state. The operator's override (ADR 0245 §5) applies.
**The first registrations** are imports, the node's first adoption: the operator's two rule files on the
laptop as the first personal rules; the laptop's skills and the workstation's subagent imported or removed on
the operator's word; the preference keys of the laptop's `settings.json` as the operator's settings, and its
`autoMode` key moved to the managed settings at the terminal; `.hal-binding.json` and the credentials copy
removed on the operator's word; the retired `cleanup` skill and the three stale instruction files found on
four nodes on 2026-10-09 removed with copies kept. Each is a condition until imported or removed.
**Lessons.** The module keeps a `lessons` register in its state: per lesson, when and on which node it was
proposed, the session where known, the **trigger** (the operator's words), the **cause** (what the agent did),
the **rule** (a personal rule in the operator's second person), an optional **before** (a tool name, command
prefix or path pattern), and its state — `proposed`, `accepted` with who, how and when, or `declined` with the
reason. The mesh's instruction section tells every agent to record a lesson, in the same turn, when the
operator expresses frustration or corrects a way of working, after first working out why, and to say in one
line that it did. A correction goes to the register, not to the agent's own per-project memory, which stays its
notebook for facts about a project. **The first lessons are the agent's existing corrections**: once the
register exists on a node, the agent there proposes, unasked, every note in its own memory that records a
correction the operator gave, and says so in one line.
- **A proposed lesson is not a rule.** It is rendered nowhere. The module shows it to the operator for a yes:
an approve ask with Accept and Decline, or, while no channel answers approve, the list tool shows it and the
operator accepts or declines at the terminal (`lessons_accepted`, `lessons_declined`). A proposal expires
after 30 days, declined with that reason.
- **An accepted lesson is a personal rule**, registered at the operator scope with the lesson as its source,
rendered into every agent's home, and named in the home instruction file as accepted from that lesson.
- **A declined lesson stays**, with its reason; a new proposal whose rule matches a declined one is refused
with that reason, and the agent is told to raise it with the operator instead.
- **The agent is reminded.** The plugin's session-start hook (the module's own binary, beside the guard)
prints at session start the rules accepted from lessons in the last 30 days and those whose `before`
matches the working directory or repository. Before a tool runs, the guard prints the rule of every
accepted lesson whose `before` matches the tool, command or path — a reminder, once per session per rule,
never a refusal.
- **The register is readable by any agent on any node** through the list tool, each state marked, so a
proposal is never read as a rule. Recording a proposal needs no yes.
**The tools of this section**: `claude_code_personal_rule_register`, `_list`, `_unregister`;
`claude_code_personal_settings_get`, `_set`; the existing register tools with `scope: operator` for skills,
commands and subagents; `claude_code_config_import` taken again, at the operator scope, by the two routes;
`claude_code_config_status` extended with the drift findings, the unknown entries, the watched entries
and the pending requests;
`claude_code_lesson_register`, `_list`, `_accept`, `_decline`. Every tool that writes into the operator's
home goes through the two routes above.
## 11. Shared instruction sections an agent proposes and the operator approves
*Added 2026-10-10 by [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).*
An instruction section for every node, or for some, has two ways in: the controller's terminal (issue 340), and **a
proposal an agent makes and the operator approves** on a channel that proves who answered. A hand-over, a rule
learned, the state of a piece of work: each is a named section, read first in every session it reaches.
| Step | What happens |
|---|---|
| propose | `claude_code_instructions_propose` (scope `mesh`, or `node` and the nodes; a name; the text, or `remove`) refuses what issue 340 refuses (any `@` after a non-letter included), what a reader cannot see, headings of level one to three, a secret's shape, more than 16 KiB, a name the terminal sets, and at once when no channel proves its sender; keeps the proposal in the `proposals` state; publishes one ask under the module's name on `operator-channel` |
| ask | composed from the proposal alone: "Add instructions '<name>' to every Claude session on the mesh?", the text whole when short and sayable, else its first lines, its size and fingerprint and the terminal line that shows all of it; Approve and Decline both at the level approve, each binding the proposal's act; a day to answer |
| prove | every node reads the router's own record of the ask under the module's name, composes the ask again, judges the record as the module would have made it, holds the warrant to it (the ask's digest, the option's bound act, the text's digest, in time), and only then writes the section into its own `approved.json`; decided once per node, oldest warrant first, never older than the section's last applied change |
| render | the managed instruction file gains "Instructions the operator approved", after the terminal's sections, from the node's own file, never from the bus |
| remove | a proposal with `remove`, approved the same way; a deleted bus record removes nothing |
| record | each node that decided an answer asks the controller's `warranted`, which reads the router's record and writes the hand-act log once per ask |
| list | `claude_code_proposals`: every proposal, where it stands on this node, and with an id the whole text and digest |
A node away for more than the 30 days the router keeps its record, or joining later, cannot prove an earlier
approval and does not render it; the list says so. The module uses the `operator-channel` seat, which grants it the
asker's subjects and its own records there.
## How it is checked
| Check | Defends |
@@ -373,8 +530,21 @@ a login).
| a switch asked of the seat through the mesh MCP server changes the licence and the token on the node; no tool answer and no log line holds a token | ADR 0183 |
| the API-key binding writes nothing under the home and the agent authenticates through the helper | ADR 0183 |
| the module's test: keys set in `managed_settings` (an auto-mode allow list, a permissions list) appear in the rendered managed settings file, a setting naming the attribution, the connectors key or a key-helper is overridden, and a key-helper appears only for an API-key binding | ADR 0213 |
| the module's render test: a registered skill, subagent, command, hook and output style land in the `nox-mesh` plugin; a tool server, a setting and an instruction section in their managed files; for one machine of two, a mesh item on both, a node item on one, a home item only in that home; a setting naming the marketplace keys is overridden | ADR 0216 |
| the module's test: a home name the person already uses is refused, unregistering removes only the placed path, and an item above 256 KiB is refused | ADR 0216, ADR 0182 |
| the module's render test: a registered skill, subagent, command and output style land in the `nox-mesh` plugin (a hook and a setting are refused since issue 340, and the terminal's land from the module's settings); a tool server and an instruction section in their managed files; for one machine of two, a mesh item on both, a node item on one, a home item only in that home; a setting naming the marketplace keys is overridden | ADR 0216, issue 340 |
| the module's test: a name registered twice is refused, unregistering removes only the rendered path, and an item above 256 KiB is refused | ADR 0216, ADR 0182 |
| the module's render test over a seeded home: every configuration path of §10 matches the registrations byte for byte, every excluded entry is byte-identical before and after, an unknown entry is reported; a program-written key of `settings.json` is taken over into the operator's settings, a key outside the list is drift | ADR 0273 §1 |
| the module's render test for one machine of three: a personal rule lands under `rules/` and in the home instruction file in the operator's own home on two and in the agent account's home on the third; a hook, a permission rule, a tool server and an item holding a shell line are refused at the operator scope | ADR 0273 §2 |
| the module's tests through its real tool dispatch: a register, import or remove at the operator scope, or for an operator's home, publishes an ask and writes nothing; with a warrant for that ask it acts once; another ask's warrant, or a replayed one, is refused; while no channel answers approve the ask is refused naming the terminal's line | ADR 0273 §3, issue 340 |
| the module's tests with a clock: a rendered file changed by hand (`settings.json` among them) is written back on the look that finds it, within a minute, its hand version kept and shown; a `hooks`, `permissions`, tool-server or `autoMode` key added on disk is written back and never taken over; a missing file is rendered again on that look; before a node's first adoption an unrendered file under `rules/`, `skills/`, `commands/`, `agents/`, `hooks/`, `output-styles/` is left and reported, after it the same file is moved into `home-copies`, restorable, and raised; an entry under neither list, `.hal-binding.json` and a `.credentials.json.*` copy are raised and left; a key other than `permissions` in `settings.local.json` and a marketplace or plugin other than the vendor's and `nox-mesh` are raised; the condition clears on a clean look; the controller's test that `agent-home-drift` is a warning the first-node gate never reads as a fault | ADR 0273 §1, §4 |
| the module's guard tests: a write, append, copy, move, in-place edit and removal under each rendered path, and aimed at `settings.local.json` and a `.json` under `plugins/`, refused naming the tool; `claude plugin install`, `claude plugin marketplace add` and their in-session forms refused; a write under `projects/` passes | ADR 0273 §4 |
| the module's test: a home-scope registration of a name and kind already registered at the operator scope is refused naming it, and the render holds the operator's item | ADR 0273 §3 |
| the module's test: a tool called for the operator's home writes nothing there, records one pending request and answers a `nox settings set claude-code '…'` line that, applied to the current layer, adds that item and changes no other key; the request is dropped once the layer holds it, and after 7 days | ADR 0273 §3 |
| the module's tests over the lessons register: register, list, accept, decline, expiry; only accepted lessons reach `rules/`; a proposal matching a declined rule is refused with the reason; the session-start hook prints the recent and matching rules; the guard prints a matching rule once per session and refuses nothing for it | ADR 0273 §5 |
| the module's render test: the rendered managed instruction file's `working-on-the-mesh` section holds the paragraph on when a lesson is recorded, that a correction goes to the register and not to the agent's memory, and that existing corrections are proposed first; the test fails when the paragraph is taken out | ADR 0273 §5 |
| live: on the laptop, `~/.claude` holds the rendered configuration and the working state and nothing else; a rule written by hand into `rules/` raises `agent-home-drift` within a minute; a rule set at the terminal is in a new session on every node; a correction given in a session is in the lessons list in that turn, accepted at the terminal, and named at the start of the next session on another node | ADR 0273, the exit of WP7–WP9 |
| the module's tests through its real tool dispatch and with a router's record: a proposal asks at the level approve and changes nothing; an approval proved from the router's record renders on another node; a decline, an expiry, a refusal, a changed text, another ask's words, a late answer, a wrong level or another ask's warrant render nothing; a warrant acts once; a removal needs approval and a deleted record removes nothing; a node scope reaches its nodes only; no verified-sender channel refuses at once; issue 340's refusals hold; the text is never logged | ADR 0274 |
| the controller's tests: `warranted` records who chose from the router's record, once per ask, and refuses an open ask, one without a choice, and another asker's | ADR 0274 §6 |
| live: a proposal for every node shows on the operator's phone with Approve and Decline; after Approve a new session on another node reads it | ADR 0274 |
| live: a skill registered at the mesh scope is offered as `nox-mesh:<name>` in a new session on each machine | ADR 0216 |
| the module's test: the "instead of" table is generated from the controller's answers, ordered and cut; the guard refuses ssh to a mesh machine by name, domain, internal name, address, alias and jump, and the local work-arounds for a mesh name, naming the tool — and lets git to the forge, ssh beyond the mesh and a command merely mentioning ssh through; the override is honoured only from the session's start and only when recorded | ADR 0245 |
| the mesh MCP server's provision resolves by co-location; a machine without the mesh MCP server refuses the module by name | ADR 0027, ADR 0152 |
@@ -392,6 +562,9 @@ a login).
directory in it, bound to its own licence through the manager (to-be 39 §5); that is for when workers
exist, and nothing here changes for it.
- **The package repository seat** (§7).
- **A per-project lesson, lessons for an agent the mesh runs, and a surface of the operator's own** that can
tell the operator from an agent, which would be a third route into the operator's home (ADR 0273). For the mesh's
shared instruction sections, the operator channel is that surface since ADR 0274 (§11).
- **How the module's tools are run** is decided: the node's tool runner, host-side
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
The managed files and the credential write are tools of this module that runtime serves. Until the
@@ -113,7 +113,7 @@ resolver is asked by the second graphical module; the display server itself is g
## 6. The order of the build
1. **The operator account on every node** — `mesh-controller node` with the login name; empty on
1. **The operator account on every node** — `nox node account <node> <login>` at the controller's terminal; empty on
all four today. Nothing home-scoped composes before it.
2. **The tool runner** — `mesh-host` supervises it; mesh-tools serves bundles from memberships
and reloads; mesh-controller composes the bundle into the declaration and the memberships to one
@@ -1,9 +1,10 @@
---
layer: to-be
status: implemented
status: in-progress
code: [mesh-catalog modules/claude-code, mesh-catalog modules/claude-licence-manager]
updated: 2026-10-04
updated: 2026-10-09
decisions:
- 02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
@@ -194,6 +195,131 @@ designs 36 and 39 set to `implemented` with the as-is written
**Proof.** `claude_code_status` answers on every node; the refusal's notification arrived; the catalogue
has no module built on the old placement.
## The home, personal rules and lessons — WP7 to WP10
*Added 2026-10-09 by [ADR 0273](../../02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md);
design 36 §10 is the authority on what is built.* WP0–WP6 ran and stay as recorded above; these four packages
open the document again. All of it is in `mesh-catalog`, in the `claude-code` module, except where a line
says otherwise. The controller needs nothing new: the module's settings layer is already the terminal's
(issue 340), and the asker's client is the shared library's (ADR 0259 §10 step 3).
```
WP7 the home is the module's: render, drift, guard (mesh-catalog) ── two days
WP8 personal rules and the operator's items, live (the live mesh) ── half a day, the operator's
WP9 lessons: register, ask, reminders, the instruction section (mesh-catalog) ── two days
WP10 the rest of the nodes, and the as-is (the live mesh) ── half a day
```
### WP7 — The home is the module's
*mesh-catalog. Two days.*
1. **The manifest.** The module `uses` the `operator-channel` seat, as an asker. New settings, each a list
of items whose text is a list of lines (a setting holds no line break, issue 339): `personal_rules`,
`personal_skills`, `personal_commands`, `personal_agents`, `personal_output_styles`,
`personal_keybindings`, `personal_settings` (preference keys only; `autoMode`, `hooks`, `permissions`,
tool-server, `env`, `apiKeyHelper` and `statusLine` refused), `lessons_accepted`, `lessons_declined`. New
state buckets: `personal` (the operator-scope registrations and the lessons accepted into them), `lessons`
(WP9), `home-copies` (the hand versions, the files moved out after adoption, and the removal copies ADR 0243
already keeps; never a credentials copy), `pending` (requests waiting for the terminal), and a per-node
record of the first adoption. The documentation gains the lists of design 36 §10: the rendered paths, the
excluded working state, the two watched entries, and the entries named as neither.
2. **The renderer.** From the operator-scope registrations and the terminal's settings, the home instruction
file, `rules/`, `skills/`, `commands/`, `agents/`, `output-styles/`, an empty `hooks/`, `keybindings.json`
and `settings.json`, written as the
agent's account (through §9's helper where that is an agent account), only when content changed, and a
record of what was rendered. The program-written preference keys of `settings.json` taken over into
`personal_settings` when the program changes them; every other key written back.
3. **Drift.** A comparison at least once a minute and at once on a file watch, over the rendered paths, the
two watched entries and the directory's top level; findings in the module's health statement (ADR 0240)
naming each path and what differs. **The look that finds a difference acts**: a changed rendered file is
written back on that look, its hand version kept in `home-copies` first (a key that decides what runs is
never taken over); a missing one rendered again; a file nobody rendered under `rules/`, `skills/`,
`commands/`, `agents/`, `hooks/` or `output-styles/` left and reported before the node's first adoption,
and after it moved into `home-copies`, restorable, and raised; an entry under neither list, a
`settings.local.json` key other than `permissions`, and a marketplace or plugin other than the vendor's and
`nox-mesh` raised and left. The controller:
the condition kind `agent-home-drift`, a warning that needs the operator, exempt at the first-node gate as
the relogin and directory-used-as-found kinds are (one small change in `mesh-controller`).
4. **The two routes.** Every register, import and remove tool at the operator scope or for an operator's
home publishes an approve ask through the asker's client, with the item in full and a digest over text,
name, kind and scope, and acts once on the warrant; while the router carries no channel that answers
approve for this module, it records the request in `pending` and answers the exact
`nox settings set claude-code '<json>'` line, merged into the current layer so nothing else changes. A
home-scope name an operator-scope item of the same kind uses is refused. The home scope into an agent
account's own home stays as issue 340 built it.
5. **The guard.** A refusal of any shell command or file edit whose target is under a rendered path, is
`settings.local.json`, or is a `.json` file under `plugins/` (a redirect, append, copy, move, in-place
edit, removal), and of `claude plugin install`, `claude plugin marketplace add` and their in-session forms,
naming the tool; nothing for the rest of the working state; the override of ADR 0245 §5.
6. **The tools.** `claude_code_personal_rule_register`, `_list`, `_unregister`;
`claude_code_personal_settings_get`, `_set`; `scope: operator` on the skill, command and agent register
tools; `claude_code_config_import` taken again at the operator scope by the two routes;
`claude_code_config_status` extended with the drift findings, the entries the module knows under
neither list, the watched entries and the pending requests.
**Proof, before anything runs live.** The module's tests of design 36 "How it is checked" for ADR 0273 §1,
§2, §3 and §4, each failing on the commit before. The catalogue's checks pass.
### WP8 — Personal rules and the operator's items, live
*The live mesh. Half a day, and it is the operator's.* One workstation first, as every package here.
1. The module with WP7 reaches the laptop, where no adoption is recorded yet. `agent-home-drift` is raised
there, naming the two rules, the five skills, `settings.json` (its keys differ from an empty registration),
`.hal-binding.json` and `.credentials.json.testbak` as entries under neither list, and nothing else.
2. At the terminal the operator sets `personal_rules` with the two rules, `personal_settings` with the five
preference keys, `managed_settings` (for the laptop or the mesh) with the `autoMode` key, which is a
permission rule and cannot be imported at the operator scope, and `personal_skills` with the skills they
keep; removes the rest, `.hal-binding.json` and the credentials copy with the removal tool (no copy kept of
the credentials copy). The operator then records the laptop's first adoption. The condition clears.
3. The module reaches the other three nodes. On each, the condition names the retired `cleanup` skill and
the instruction file (and the workstation's subagent); the operator removes or imports each, once, and
records that node's first adoption.
**Proof.** On every node `~/.claude` holds the rendered configuration and the program's working state and
nothing else; a new session on the workstation has the laptop's two rules in its context; a rule written by
hand into `rules/` on the laptop is moved into the copies and raised within a minute; a hand edit of
`settings.json` is written back within a minute with the hand version shown by the status tool.
### WP9 — Lessons
*mesh-catalog. Two days.*
1. **The register**: the `lessons` bucket; `claude_code_lesson_register` (trigger, cause, rule, optional
before; refused when its rule matches a declined lesson's, with the reason), `_list` (the three states
marked apart), `_accept` and `_decline` (the two routes of WP7 step 4, since they write into the
operator's home); expiry of a proposal after 30 days; `lessons_accepted` and `lessons_declined` read
from the terminal's settings.
2. **Accepted lessons as personal rules**: registered at the operator scope with the lesson as their source;
the home instruction file names each as accepted from its lesson.
3. **The reminders**: a session-start hook in the `nox-mesh` plugin, the module's own binary, printing the
rules accepted in the last 30 days and those whose `before` matches the working directory or repository;
the guard printing the rule of a matching accepted lesson once per session before the tool runs,
refusing nothing for it.
4. **The instruction section**: the mesh's `working-on-the-mesh` section gains the paragraph of design 36
§10 on when an agent records a lesson, that a correction goes to the register and not to the agent's own
memory, and that its existing corrections are proposed first; a render test holds the section to it; and the `talking-to-the-operator` section one line saying the agent
says in one line that it did. Both are set at the terminal, as every mesh-wide section is since issue 340.
**Proof, before anything runs live.** The module's tests for ADR 0273 §5 in design 36 "How it is checked".
5. **The first lessons**: on the laptop, once the register exists, the agent proposes, unasked, every note in
its memory that records a correction the operator gave (at least 9 on 2026-10-09).
**Proof, live, on one workstation.** The laptop's memory corrections are in `claude_code_lesson_list` as
proposals. In a session, the operator corrects a way of working; the lesson is in
`claude_code_lesson_list` in that turn; the operator accepts it at the terminal; the next session on another
node names it at start; a tool whose name matches its `before` is preceded by the rule once.
### WP10 — The rest of the nodes, and the as-is
*The live mesh. Half a day.* WP9 on every node; the as-is of the agent
([00-as-is/15](../00-as-is/15-the-agent-and-its-licences.md)) rewritten to say what the home holds and how
a lesson travels; design 36 and this document set to `implemented` ([playbook 02](../../00-META/process/02-graduation.md)).
**Proof.** The live line of ADR 0273's "How it is checked", on every node.
## What is deliberately not here
- **The package repository seat** for a distribution that does not carry the agent's package (design 36
@@ -213,4 +339,5 @@ Each package's proof is run when the package is finished and its line here gains
commit, as design 38 does. A package whose proof fails is not reworded; the failure is recorded under it
and the package stays open. When WP2's live proof runs, design 36 moves to `in-progress` with its owning
repository; when WP5's does, design 39 does too; and when WP6's does, both move to `implemented`, with
the as-is written.
the as-is written. WP6's did, on 2026-10-04. ADR 0273 opened WP7–WP10 on 2026-10-09, so design 36 and this
document are `in-progress` again until WP10's proof runs.
@@ -2,7 +2,7 @@
layer: to-be
status: in-progress
code: [mesh-controller, mesh-host, mesh-tools, mesh-catalog, mesh-sdk, mesh-lab]
updated: 2026-10-09
updated: 2026-10-10
decisions:
- 02-DECISIONS/0271-a-send-held-for-the-buss-planned-step-is-told-to-the-operator-at-once-and-a-change-that-needs-it-says-so-before-it-merges.md
- 02-DECISIONS/0265-a-verb-that-reads-is-answered-on-the-serving-controllers-own-connection-and-every-connection-says-what-it-is.md
@@ -72,6 +72,8 @@ Every kind of state the core keeps, and the one component that writes it. Anyone
| State | Writer | Kept in | Others |
|---|---|---|---|
| a machine's declaration | controller (lease holder) | the bus, last per subject | read |
| a hand-over asked of a machine ([issue 356](../../04-ISSUES/356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/00-report.md)) | controller, at its terminal | the machine, beside its state | the engine verifies the mesh's signature and records it, or refuses |
| a fresh setuid search asked of a machine ([issue 361](../../04-ISSUES/361-the-control-nodes-agent-account-is-never-judged/00-report.md)) | controller, at its terminal | the machine, which throws its last search away | the engine verifies the mesh's signature and starts it, or refuses |
| a machine's applied state and its report | the node-engine's apply queue (§6) | the machine; the report on the bus | the reconcile and a delivery *enqueue*, never apply |
| the controller lease | the controller instance holding it | key-value `mesh-controller_lease` | a candidate waits |
| plans and their tiers | controller (lease holder), compare-and-set on the plan's revision | the controller's store | read through `plans` |
@@ -96,7 +98,8 @@ this table marks it shared. Changing a writer is a change to this table, through
**How it is enforced** ([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)):
the table is compiled into the controller, each row the bus carries with the subjects a write of it
publishes to — a declaration `mesh.node.*.declare`, a report `mesh.control.*.report` (its own machine's
publishes to — a declaration `mesh.node.*.declare`, a hand-over asked `mesh.node.*.ask.hand-over` (signed as
well, because a responder can reach a subject through a reply, which no grant refuses), a report `mesh.control.*.report` (its own machine's
only), the controller's buckets, stream creation, update and deletion and durable consumer creation, a
build's outcome, a merge announced, a provider's standing — and its writer among the principals. A
grant that overlaps another writer's subject is refused at composition, naming the state and its
@@ -4,6 +4,7 @@ status: designed
code: []
updated: 2026-10-08
decisions:
- 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/0253-a-condition-says-itself-to-the-operator-in-plain-words-beside-its-summary.md
- 02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md
- 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md
@@ -13,8 +14,8 @@ decisions:
**The mesh, its modules and its agents tell the operator things and ask them things; the operator
answers, or writes first. Each exchange travels on a channel whose declared capabilities can carry it,
chosen by where the operator is working. An answer that performs an action is checked and performed by
the controller, never by the channel or the asker** ([ADR 0234](../../02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md),
chosen by where the operator is working. An answer that performs an action is judged by the router, which
issues a warrant; the asker performs it with its own grants, never the channel or the router** ([ADR 0234](../../02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md),
from [research 028](../../01-RESEARCH/028-the-meshs-output-channel/00-overview.md)).
This design grows the minimal output channel of [to-be 45](45-a-core-that-cannot-fail-silently.md) §5.
@@ -28,14 +29,13 @@ That form stays the first step; §13 below is the order in which it becomes this
controller's conditions ──┐ messages and asks ┌──► channel/telegram
modules (notify) ────────┼──► required capability → work context ──┼──► channel/desktop
agents (ask) ────────┤ → severity → escalation └──► channel/<next kind>
controller (authorise │ presence (current state only)
any module (ask) ─────────┤ presence (current state only)
request) ────────────────┘ ▲ ▲
│ │ answer (ordinary asks) intake bench (in)
seen ───┘ └────────────────────────────── intake/telegram
intake/desktop
CONTROLLER ◄──── authorise answer (authorising asks, intake holders only) ─┘
│ checks capabilities, identity, code, digest; performs; records the hand-act
└──► ask-answered
ASKER (any module) ◄── warrant on decided.<asker> ── ROUTER judges: token, identity,
│ acts with its own grants; records the warrant capabilities, level; one warrant per ask
watcher's watcher (outside the seats, own bot, not on the control node) ──► Telegram directly
self-check and watcher ──► outside dead-man service
@@ -91,14 +91,16 @@ bot) is held by one module claiming both seats under one kind.
| `kind` | the holder's kind |
| `what` | `message` (written first), `choice` (a button or 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 |
| `trusted` | whether the sender is on the **controller's** list of the operator's identities. A holder never sets it: a raw intake event is untrusted by construction, and only the router stamps it, from the controller's answer (§4) |
| `trusted` | whether the sender is on the **router's** list of the operator's identities. A holder never sets it: a raw intake event is untrusted by construction, and only the router stamps it, from the controller's answer (§4) |
| `conversation` | an opaque handle; sending on it reaches the same chat, room or thread |
| `in-reply-to` | the ask or message it answers, if any |
| `payload` | the text, or the option's token. **Never a code** (§10) |
| `at` | when |
**Answers go to the ask's owner by request and reply**: the router's `answer` for an ordinary ask, the
controller's `authorise answer` for an authorising one; both refuse an answer that is not trusted.
**Answers arrive only as intake events of the holder's own kind** (*amended 2026-10-08, ADR 0259*): a
`choice` carries the router's token and the sender as the service authenticated it, and the router judges
it (§10). A typed code goes by request and reply on the seat's `proof` subject, never in an event. There
is no `answer` tool: any tool runner may call any tool, so a tool call proves nothing about its caller.
**Everything else is an event on the seat**, taken by the router, which re-emits it stamped (§4): a
trusted `message` as an **operator message**, addressed (§5); anything untrusted as **input**, for
consumers that accept untrusted input (a later mail rule takes `mail`). The router takes `seen` for the
@@ -109,14 +111,13 @@ grants it (the open gap of [to-be 32](32-what-a-module-declares.md) §1).
## 4. Who counts as the operator
- **Only a sender on the controller's list of the operator's identities is the operator.** The list
is kept per intake kind in the controller's state. While no factor exists, an identity is enrolled
- **Only a sender on the router's list of the operator's identities is the operator**
(*amended 2026-10-08, ADR 0259:* the router's, not the controller's). The list is kept per intake kind in
the router's state, and changed only by linking and unlinking, never by a setting. While no factor exists, an identity is enrolled
only at a terminal; once one does, adding or removing an identity is a destroy ask (§10). Telegram's linking verb
(§11) produces such an ask.
- **The controller serves an identity check** (kind and sender in, trusted or not out). The router keeps
the answers as current state, refreshed when the controller announces a change to the list, and
stamps every envelope it re-emits with `trusted`. **No holder's word is taken for it**, and the
controller checks again itself before authorising (§10).
- **The router checks every sender against its own list** and stamps every envelope it re-emits with
`trusted`. **No holder's word is taken for it.**
- **An untrusted envelope** may start work for a consumer that declares it accepts untrusted input. It
can never answer an ask, authorise, or be delivered to an agent as the operator's words. An untrusted
sender writing to the bot is told nothing beyond a fixed line, and the attempt is a warning condition
@@ -208,10 +209,10 @@ The event **`ask-answered`**, with the ask's id and any conversation handle. An
### The router's verbs and events
`ask`, `ask cancel`, `asks`, `answer` (intake holders only), on the `operator-channel` seat beside its
existing `notify`, `open` and `history`. Events `ask-opened`, `ask-answered`, `ask-closed`. The router
owns every ask that authorises nothing; an authorising ask is owned by the controller (§10) and carried
by the router like any other.
*Amended 2026-10-08, ADR 0259:* an ask and its cancel are accepted on the `operator-channel` seat under
the asker's name; the answer, a warrant or the ask's end, is the seat's event `decided.<asker>`; the tool
`asks` lists them, beside the existing `notify`, `open` and `history`. The router owns every ask,
authorising or not (§10).
## 7. The capability vocabulary, `channel-capabilities/1`
@@ -234,9 +235,9 @@ withdrawn from routing and reported until it passes. A new word is a new version
| | `threads` | an answer is tied to what it answers | a reply to A carries A in `in-reply-to` |
| | `operator-first` | the operator can write unprompted | a simulated message yields a `message` envelope |
| trusting | `verified-sender` | the answer came from the operator's own account, through a holder no agent shares | a choice from an identity not on the list is dropped and reported; placement checked |
| | `exact-render` | the ask is shown as the controller rendered it | rendered text equals the controller's, byte for byte |
| | `code-factor` | a typed code reaches the controller by request and reply, never judged by the holder | a code never appears in an event; it is deleted from the conversation where the service allows |
| | `key-factor` | a security key's assertion over the controller's challenge reaches the controller | the challenge is the controller's; the signature is checked by the controller |
| | `exact-render` | the ask is shown as the router rendered it | rendered text equals the router's, byte for byte |
| | `code-factor` | a typed code reaches the router by request and reply, never judged by the holder | a code never appears in an event; it is deleted from the conversation where the service allows |
| | `key-factor` | a security key's assertion over the router's challenge reaches the router | the challenge is the router's; the signature is checked by the router |
What the first holders declare:
@@ -298,7 +299,7 @@ is not exempted.
- **Allowed in a message's words:** roles, words, and the mesh's own machine names.
- **Refused:** domains, addresses, paths, and anything shaped like a secret — in words, labels and
references alike.
- **A refusal is said to the sender** — the `notify`, `ask` or `authorise request` call is refused naming
- **A refusal is said to the sender** — the `notify` call or the ask is refused naming
the offending part, so the asker can rephrase or move the specific into a reference — and raises
`channel-refused`. The offending part is never sent to a channel.
@@ -316,17 +317,61 @@ is not exempted.
## 10. Asks that authorise
### The verb table and the controller's verbs
*Amended 2026-10-08, [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 router holds every ask, authorising or not, and
judges each answer; the asker performs the chosen option on a warrant.** The controller keeps no channel,
no identity and no factor: it asks and acts like any other module. What follows in this section reads
with these replacements.
- **The controller's verb definition gains `authorises`:** the tier, and the arguments that make up
### Asks, warrants and answers on the bus
`<asker>` is the asking module's name, `<kind>` a channel's kind. Each subject's last token names its
publisher, and the bus grants it to that publisher alone.
| What | Subject or bucket | Kept in | Publisher | Reader |
|---|---|---|---|---|
| an ask | `mesh.seat.operator-channel.accept.ask.<asker>` | the seat's work queue, 7 days | the asker, its own name | the router |
| a cancel | `mesh.seat.operator-channel.accept.cancel.<asker>` | the same | the asker, its own name | the router |
| asks and their state | the router's bucket `asks`, key `<asker>.<id>` | its state; closed records 30 days | the router | the router; each asker its own prefix |
| answer tickets | in each ask's record: one per option and kind, used once | its state | the router | the router |
| the operator's identities | the router's bucket `identities`, key `<kind>.<identity>` | its state | the router | the router |
| a warrant, or an ask's end | `mesh.seat.operator-channel.event.decided.<asker>`, message id `<asker>.<id>` | EVENTS, deduplicated | the router | the asker, its own name |
| show, edit, say | `mesh.seat.channel.accept.<show\|edit\|send>.<kind>` | the seat's work queue, one worker per kind | the router | that kind's holder |
| a choice, a link asked, a failed message, standing | `mesh.seat.intake.event.<choice\|link\|failed\|standing>.<kind>` | EVENTS | that kind's holder | the router |
| which kinds exist, and what each promises | the router's membership (`seat-traffic.kinds`) | ASSIGNMENTS | the controller, from the claims | the router |
| a code | `mesh.seat.intake.proof.code.<kind>` | nothing: core request and reply | that kind's holder | the router answers |
**An ask** carries an id the asker chose; the question in plain words; at most four options, each a label,
what it does in plain words, its assurance level (`acknowledge`, `approve`, `destroy`) and an id; who may
answer (`operator`); an expiry; what happens on expiry, in words; and optionally what it is about (a
condition's key), so a newer ask about it replaces the older. It is refused at once, on its asker's
`decided` subject, when its words break a rule, when no ready channel can carry one of its options' levels,
or when the asker already has three open.
**A warrant** carries the ask's id and asker, the option, `by` ("the operator, via telegram, user id
verified"), the channel module, the proofs, the level met and when. No secret. An ask that ends without one
ends `expired`, `cancelled`, `replaced` or `refused`, on the same subject.
**The router judges a `choice` of kind K with token T from sender S**, refusing at the first failure and
saying it on K's channel: T is known, unused, unexpired and issued for K; the ask is open and T's option is
its own; S is linked for K; K's capabilities, from the controller's record of its claim, meet the option's
level; for `approve`, S was linked at least an hour ago; then the ask is closed by compare-and-set and the
warrant published. Every copy is edited to the outcome.
**The asker** acts once per ask id, only on a warrant from its own subject, with its own grants, and its
record of the act names the warrant (`by`, `via`, `ask`, `proofs`). The hand-act log counts it as a person's
decision.
**The verbs the controller was to serve** (`authorise request`, `authorise answer`,
`authorisations`) are not built: an ask on the seat replaces the first, an intake `choice` the second, and
the router's tool `asks` the third. The tiers, the proofs, the direct-call rules and the factors stand, and
the factors are the router's state.
### The verb table
- **Each asker's verb definition gains `authorises`:** the tier, and the arguments that make up
the exact state a person must see (for `retire approve`, the set of consumers).
- **`authorise request`** — callable by anyone (an agent, the router on a condition's behalf). Carries
the verb and its exact arguments, why, and optionally a conversation handle. The controller renders
the ask, stores it with an opaque id, its expiry and a digest of the state shown, and emits
`ask-opened` with itself as owner. **Nothing is performed.**
- **`authorise answer`** — granted to intake holders only. Carries the ask's id, the option, the
sender's identity, and the code or key assertion where the tier needs them.
- **`authorisations`** — open and recent authorising asks.
- **An authorising ask** is published by its asker on the seat (above); the router renders it and stores
it with its expiry. **Nothing is performed** until the asker holds a warrant.
### What may be authorised, and its tier
@@ -343,7 +388,7 @@ is not exempted.
- **P1, a verified sender:** a Telegram tap from the operator's own account, through a holder placed
where no agent runs as the operator.
- **P2, a TOTP code** from the operator's authenticator app, verified by the controller. Valid for the
- **P2, a TOTP code** from the operator's authenticator app, verified by the router. Valid for the
current or previous 30-second step; accepted once. **This is the proof everything above acknowledge
can rest on.**
- **P3, a security key's touch bound to the ask** (the challenge is a hash of the ask's id and the
@@ -372,26 +417,27 @@ is not exempted.
5. **No agent authorises.** An agent asks. Agents' grants hold no authorising verb.
6. **Only a trusted sender authorises** (§4); an untrusted answer is refused before any other check.
### The controller's checks, in order, refusing at the first failure
### The router's checks, in order, refusing at the first failure
1. The ask is open and unexpired.
2. The caller holds an intake kind — by the controller's own seat records, never the request's claim.
3. That kind's declared capabilities, from the controller's records, satisfy the tier, and the proofs
2. The answer came on an intake kind's own subject — which the bus grants that kind's holder alone.
3. That kind's declared capabilities, from the controller's record of its claim, satisfy the tier, and the proofs
present are enough.
4. A P1 answer's sender is on the controller's list of the operator's identities for that kind.
4. A P1 answer's sender is on the router's list of the operator's identities for that kind, and for
approve was linked at least an hour ago.
5. A code is valid for the current or previous step, and unused.
6. A key assertion verifies against the enrolled credential, over this ask's challenge, with user
presence.
7. The state now has the digest it had when shown; otherwise the ask is void and a new one is
requested.
7. The asker checks that the state still is what it asked about before it acts; otherwise it refuses
the warrant and asks again.
Then it performs as itself, records the hand-act, closes the ask with a compare-and-set so a second
answer loses, and emits `ask-answered`. Every copy is edited to the outcome and its buttons removed.
Then it closes the ask with a compare-and-set so a second answer loses, and publishes the warrant; the
asker performs as itself and records the hand-act. Every copy is edited to the outcome and its buttons removed.
### Direct calls
`retire approve|reject`, `cleanup delete`, and `pin` while `binding-kept` names it refuse any caller
except through `authorise answer`, or **break-glass** at the mesh MCP server with a code — recorded and
except on a warrant, or **break-glass** at the mesh MCP server with a code — recorded and
announced on every channel as break-glass. `retire approve` takes **`expect`**, the set it approves,
and refuses if the set now differs. `conditions silence` stays callable; a silence an agent sets is
said on the away channel, with its why.
@@ -403,10 +449,10 @@ id) and **`proofs`** (which of P1, P2, P3). `by` reads "the operator, as <kind>
### The factors
The operator's identities, the TOTP seed, its recovery codes and any enrolled key are the controller's
own state. The seed is made by the mesh and shown once, as a URI, to a plain terminal — never through a
The operator's identities, the TOTP seed, its recovery codes and any enrolled key are the router's
own state (ADR 0259). The seed is made by the mesh and shown once, as a URI, to a plain terminal — never through a
channel or an event. Re-enrolling a factor is a destroy ask, except by recovery below. A code travels
only by request and reply from an intake holder to the controller, and is deleted from the conversation
only by request and reply from an intake holder to the router, and is deleted from the conversation
where the service allows.
### Recovering the factor
@@ -414,33 +460,43 @@ where the service allows.
A lost phone must not lock the mesh's only person out for good, and re-enrolling is a destroy ask that
needs the lost factor's code. So:
- **`factor enrol`**, at a terminal: the controller makes the seed and **ten one-time recovery codes**,
- **`factor enrol`**, at a terminal: the router makes the seed and **ten one-time recovery codes**,
shows them once together, and stores the codes **only as hashes**.
- **A recovery code counts as one P2 proof, once.** It may stand in for the app's code in any ask.
- **`factor recover`**, at a terminal, given a recovery code: a new seed and ten new codes replacing the
remaining ones. Recorded as a hand-act, and **announced loudly on every channel**.
- **The count left** is shown by `factor status` and in `authorisations`; the self-check warns when
- **The count left** is shown by `factor status` and in the router's `asks`; the self-check warns when
three or fewer remain.
- **The last resort:** root on the control node — at it, or by its SSH key — runs `factor enrol
--break-glass` there. It talks to the controller locally, never over the bus; the same verb over the
--break-glass` there. It talks to the router locally, never over the bus; the same verb over the
bus is refused. Recorded and announced as break-glass.
- **No tier requiring P2 is enabled until recovery codes exist.** The controller refuses the setting.
- **No tier requiring P2 is enabled until recovery codes exist.** The router refuses the setting.
## 11. The first holders
### Telegram — kind `telegram`, both seats, the away channel
- **Its own module**, holding the bot token and chat id as its own secrets; the router keeps no
Telegram client.
- *Revised after the security review of 2026-10-08 (ADR 0259 §8, not yet accepted):* the router and every
channel declaring `verified-sender` run as their own process and service account with a bus account of
their own, never carried by a tool runner; the controller passes `verified-sender` only for a holder so
placed, on a machine where no agent's account can become root without a person.
- **Its own module**, `telegram`, holding the bot token as its own secret; the router keeps no Telegram
client. *Amended 2026-10-08, ADR 0259:* **it runs on the control node under its own account `telegram`**,
never root or the operator's: a runtime of its own serves it as that account, on a bus account of its own
(`runs-as`), and its state directory, token and bus credential belong to it. If an agent holds root there,
none of this holds; the self-check raises a condition while an agent can become root without a person, by
its own account or through a tool that runs its command as one that can.
- **Long polling**, never a webhook; the update offset kept in its own state.
- **Buttons** carry only an opaque `a1:<ask>:<option>` (Telegram allows 64 bytes); everything else is
in the router or the controller. A tap is dropped and reported unless both the user and the chat are
- **Buttons** carry only the router's one-time token (Telegram allows 64 bytes); everything else is in
the router. A tap is dropped and reported unless both the user and the chat are
the operator's; otherwise it is acknowledged at once (the phone shows a spinner until then), handed
on, and the message edited to the outcome.
- **A destroy ask** answers the tap with a forced-reply prompt for the code; the holder reads it,
deletes it from the chat, and hands it to the controller by request and reply.
- **A linking verb** with a one-time deep-link code replaces reading the chat id by hand. Linking an
identity adds it to the controller's list, so after the first it is a destroy ask (§4).
deletes it from the chat, and hands it to the router by request and reply.
- **Linking** (*amended 2026-10-08, ADR 0259*): the operator sends `/start`; the router shows a six-digit
code on the desk for ten minutes; the operator sends it to the bot, and the router links the account.
One identity per kind. **A new link acknowledges at once and approves only after one hour**, and every
link and unlink is said on every channel. Once a TOTP factor exists, linking is a destroy ask (§4).
- **A sender not on the list** is answered with one fixed line and handed on only as untrusted input.
- **Placement:** where no agent runs as the operator. Its `verified-sender` depends on it, and the
self-check reads the placement.
@@ -456,11 +512,12 @@ needs the lost factor's code. So:
*Amended 2026-10-08, [ADR 0258](../../02-DECISIONS/0258-a-desk-click-performs-only-an-acknowledgement-and-an-answer-is-a-one-time-ticket.md):*
only an acknowledgement (Details, silencing) is offered so: any process of the account can choose an
action, so a click is never taken for the operator's word. Every other answer waits for phase 5.
- The router's desktop adapter, holding `channel/desktop` and `intake/desktop`, turns that into a
`choice` envelope.
- *Amended 2026-10-08, ADR 0259:* a module of its own, **`desk-channel`**, holds `channel/desktop` and
`intake/desktop`, sends through each machine's `node-notifier` and turns dunst's `action-chosen` into a
`choice` envelope. The desk declares no `verified-sender`, so it answers `acknowledge` options only.
- **For `text`, `number`, `date` and codes**, the notification's single action opens the
`node-launcher` seat's prompt; what is typed returns as a `reply` — or, for a code, straight to the
controller by request and reply.
router by request and reply.
- **The screen-lock holder emits lock and idle changes** for the work context.
- It carries every ordinary ask with no account anywhere, and an authorising one only with a code (or
an enrolled key's touch).
@@ -500,8 +557,32 @@ Each phase ends at its own *done when*. Owning repositories from [`repos.md`](..
| **2 — The desk's actions** | `node-notifier.send` gains actions; the dunst holder emits the chosen one; the launcher's prompt returns typed text; the screen-lock holder emits lock and idle changes | mesh-catalog | a notification with two actions returns the chosen token as an event; locking emits an event |
| **3 — The seats, and the router** | the kinded bench in the seat set; `kind` and `capabilities` on a claim; `channel-capabilities/1` and its contract tests; publishing on a seat's event subjects and its grant; `channel` and `intake` seats; the output seat's holder becomes the router and holds `channel/desktop` and `intake/desktop`; presence as current state; the operator's identity list and identity check in the controller, the router's `trusted` stamping and untrusted input (§4); the content rule allowing machine names, references and `detail`, refusals naming the offending part (§9) | mesh-controller (seat set, claim fields, registration refusals, grants, the identity list and check); mesh-sdk and mesh-tools (seat-event publishing in the shared library and the tool runner); mesh-catalog (the seats' definitions, the router) | the catalogue refuses an unknown capability and a second holder of one kind; a message is routed to the desk by capability and context; an envelope from an identity not on the list is re-emitted untrusted; a path is refused to its sender by name, and travels as a reference the desk opens |
| **4 — Asks, and operator messages** | `ask`, `ask cancel`, `asks`, `answer`; the events; kinds, life, limits, batching, history; escalation; addressing, the register of agents with kept messages, the responder; the untrusted-input rule in every agent module's instructions (§5) | mesh-catalog | the router tests of ADR 0234 pass; live: an agent's question answered at the desk, and with the desk locked, on the phone; `@mesh status` answered; a message to a stopped agent kept and said |
| **5 — Authorise, with TOTP** | `authorises` in the verb table; `authorise request`, `authorise answer`, `authorisations`; the seven checks; direct calls refused, break-glass; `retire approve` takes `expect`; the hand-act fields; the TOTP seed as the controller's state, enrolled at a terminal with ten recovery codes; `factor recover`, `factor status`, the local-only break-glass enrolment; no P2 tier before recovery codes exist; optional key enrolment; the self-check probe for the away channel; agents' grants lose the authorising verbs | mesh-controller; mesh-catalog (agents' grants, the desk's code prompt) | one controller test per refusal passes; at the desk, approve needs a code; a recovery drill re-enrols with a recovery code, and is announced |
| **6 — Telegram live** | the `telegram` module holding both seats, with buttons, replies, forced-reply codes and the linking verb, placed where no agent runs as the operator; the operator's two bots configured; the watcher assigned off the control node; the dead-man ping | mesh-catalog | the self-check says the away channel carries every tier; live drills: an approve and a destroy on a test condition answered on the phone, with the hand-acts read |
| **5a — Asks on the bus** (ADR 0259) | the kinded bench, caller-named verbs, `proof` subjects and the records users read (controller); a bundle's seat traffic, compare-and-set state and its own account (tool runner); the asker's and channel's types (shared library); the router's asks, tokens, identities, judging, warrants, expiry and linking with its hour (messenger); `telegram` and `desk-channel`; the controller asking for its conditions' actions and acting on warrants | mesh-controller, mesh-tools, mesh-sdk, mesh-catalog | a held delivery released from the phone; the hand-act reads "the operator, via telegram, user id verified"; a desk Silence goes through an ask |
| **5 — Authorise, with TOTP** | `authorises` in the verb table; the router's checks for the code; direct calls refused, break-glass; `retire approve` takes `expect`; the hand-act fields; the TOTP seed as the router's state, enrolled at a terminal with ten recovery codes; `factor recover`, `factor status`, the local-only break-glass enrolment; no P2 tier before recovery codes exist; optional key enrolment; the self-check probe for the away channel; agents' grants lose the authorising verbs | mesh-controller; mesh-catalog (agents' grants, the desk's code prompt) | one controller test per refusal passes; at the desk, approve needs a code; a recovery drill re-enrols with a recovery code, and is announced |
| **6 — Telegram live** | the `telegram` module (built in 5a) with replies and forced-reply codes, under its own account on the control node; the operator's two bots configured; the watcher assigned off the control node; the dead-man ping | mesh-catalog | the self-check says the away channel carries every tier; rehearsals: an approve and a destroy answered on the phone, with the hand-acts read |
**Switching phase 5a on: the operator's steps, in order** (amended 2026-10-09, ADR 0259). Each line is typed with
`nox` (`mesh-cli`) on the control-node, from the operator's own account, which is the controller's terminal
(ADR 0272):
1. The pull requests merge in their order, each after its checks pass on the merge result; nothing is assigned
by the merges.
2. The agent account (ADR 0266) runs on the control-node and the login shell's `execute` is withheld there
(ADR 0268): until both hold, the self-check's `agent-root` or `root-not-free` stays open and Telegram only
acknowledges.
3. `messenger`, `telegram` and `desk-channel` are assigned to the control-node (`desktop-machines` set on
`desk-channel` in the same step), and the control-node is pushed.
4. A bot is made with Telegram's BotFather, and its token is given at the controller's terminal:
`nox secret accept <control-node> telegram telegram-token`. It is announced on every channel before it is
kept. Then the control-node is pushed again, so the channel holds it.
5. The operator sends `/start` to the bot from their own account, in a private chat, and sends back the six digits
the desk shows. Nobody configures a chat id: the router's identity list is written by linking alone.
6. An hour later, `live-acceptance.sh` (mesh-lab `asks/`) runs a rehearsal: a question on the phone, approved
there, read back from the hand-act log.
Until `mesh-cli` carries standard input (ADR 0272, a later step), step 4's line cannot read the token from the
terminal; it is given with `--from` a file the controller's own account reads and nobody else does, removed
right after.
**Not touched:** the node engine (`mesh-host` repository). Presence comes from the screen-lock seat's
holder, not the engine; if a later measure shows the engine must relay it, that is a change here.
@@ -2,8 +2,9 @@
layer: to-be
status: designed
code: []
updated: 2026-10-09
updated: 2026-10-10
decisions:
- 02-DECISIONS/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md
- 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
- 02-DECISIONS/0250-a-merge-heard-only-from-the-buss-history-is-retired-and-owed-work-the-forge-cannot-do-ends.md
- 02-DECISIONS/0249-a-delivery-groups-order-rules-have-a-precedence-and-a-declared-order-outranks-them-all.md
@@ -21,6 +22,11 @@ delivery group is two or more deliveries that share a head branch name, delivere
are owned by one module, `mesh-delivery`, which records every step and asks the controller for each act.**
([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, a delivery is delivered by the walk of its batch.** The controller collects merges in a
rolling merge window and plans each batch once, as one walk with at most one commit per repository, one walk
at a time. Every delivery the walk answers ends with it
([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)).
This is the delivery of [to-be 10](10-delivery.md) carried out. To-be 10 says what is built: the comparison
of source and artifacts decides, and nothing here changes that. This design says who answers for one
commit's journey and in what order the steps are allowed.
@@ -57,7 +63,9 @@ Its id is `<owner>/<repository>@<twelve characters of its commit>`. It holds:
its diffset (what ADR 0238 called the "change plan", under the glossary's name);
- its **state**, its **transitions** (the last hundred, each with when, the event, from, to and why), and its
**machine steps** while delivering;
- once merged, **the commit it landed on the trunk as**, and the walk that delivers it.
- once merged, **the commit it landed on the trunk as**, and the walk that delivers it: the walk of the batch
that answers its merge, which may carry a later commit of its repository that contains it (see *The merge
window and the batch*).
## The state table
@@ -72,15 +80,15 @@ allows.
| checked | a verdict arrived | 1 minute | nothing: it is decided at once |
| ready | it passed; it waits to be merged | none | — |
| rejected | it failed its check | none | — |
| published | merged on the trunk its modules follow, its walk opened and asking nothing; it waits for its turn or its word | 30 minutes | `superseded` when a newer delivery took over its walk; `go` when its walk started |
| published | merged on the trunk its modules follow, its walk opened and asking nothing; it waits for its turn or its word | 30 minutes | `superseded` when a walk that does not answer its merge took over its walk; `go` when its walk started |
| held | it waits for a person; read again each tick, so it ends by itself when it moves nothing or a later merge covers it (ADR 0270) | 1 day | nothing: it is the operator's |
| delivering | its walk runs; its builds are asked and registered tier by tier | 2 hours | `delivered`, `failed` or `superseded`, as the walk's record says |
| delivered, failed, superseded, stopped, retired, covered | final | — | — |
The transitions are those of ADR 0239 decision 2 and [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). A delivery that is
merged while not `ready` goes to `held`. Its check did not pass, and only a person decides that it goes on. A
delivery merged with no walk opened for it within ten minutes is held too, saying so: nothing it moves follows
that branch, or the merge was not heard. Nothing waits silently.
delivery merged with no walk answering it within ten minutes is held too, with the true reason, and listed by
`stalled` at once. Nothing waits silently.
**What needs no person ends by itself** (ADR 0270):
@@ -107,6 +115,44 @@ module and the build: `sent` (the first machine's send, or the rest's), `judging
readings so far), `passed`, `failed`, `rolled-back`. They are read from the walk's own record in the
controller's `plan-moved` event, never inferred from time.
## The merge window and the batch
*2026-10-10, by [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).*
- **The window.** The controller puts every merge into a branch a module follows into the open **batch**.
Each merge restarts the **merge window**; it closes when none arrived for `merge-window` (90 seconds by
default) or at the latest `merge-window-at-most` (10 minutes) after the batch's first merge. The batch, its
merges and its times are kept in the controller's store and survive a restart.
- **The cut.** When the window closes and no walk is open, the batch becomes one walk: one delivery plan for
every commit in it, tiered along the module graph across repositories, with at most one commit per
repository, the latest of its branch. The walk names every merge it answers: its own commits, and the
earlier merges of a repository that its later commit contains.
- **One walk at a time.** Merges that arrive while a walk is open join the next batch. A walk that started
is never superseded. A walk still waiting for its word when the next batch is cut is folded into the new
walk, which answers every merge the folded one answered.
- **A late merge.** A merge heard after a later merge of its repository and branch was put in a walk is
answered by that walk: the assembling batch, the open walk, or, when that walk was delivered, it ends
`covered` at once.
- **The ends.** A walk done ends every delivery it answers: `delivered` for its own commits, `covered` for
an earlier merge of a repository it carried later, naming that merge. A walk that failed fails its own
commits' deliveries and marks none delivered; each earlier merge it answered is then walked on its own
commit, the newest first, one walk each, until one is delivered. A walk stopped stops them all.
- **A group inside a walk.** The pairs of a delivery group's order (ADR 0249) are edges of the walk's
tiers beside the module graph. mesh-delivery gives a walk its word only when every delivery it carries is
at its turn.
- **The bookkeeping.** mesh-delivery ties a walk to every live delivery whose merge it answers, follows a
folded walk to the walk that took it over, and never ties a walk to a delivery that has ended. A waiting
walk no live delivery holds is adopted as a delivery of its own, or listed by `stalled` at once.
- **Shown.** `plans` lists the open batch first: "assembling: 13 s left (at the latest 00:31:09); grouped:
novox/mesh-catalog@48bda475 (answers 553b7191), novox/mesh-controller@a6bc0931; plan not yet calculated",
and a batch waiting behind an open walk names that walk. The controller says the batch on the bus as
`plan-moved` in the state `assembling`, and mesh-delivery's `deliveries` and `show` say it for each merged
delivery it holds.
- **Bounds.** A batch still assembling a minute past `merge-window-at-most` with no walk open, and a batch
waiting behind an open walk past that walk's bound, each raise a condition. A merged delivery that no walk
answers within ten minutes is held with the true reason (the open walks of its repository and that none
names its merge, or that the controller holds none) and listed by `stalled` at once.
## A delivery group
- **Membership**: the pull request's head branch name, across the mesh's repositories, among open pull
@@ -161,7 +207,7 @@ In this order, each by its owner:
| `groups` | every group with its members in order and its derived state |
| `what-if` | the delivery plan a diffset would have, asked of the controller's planner, kept nowhere |
| `table` | the state table and the machine-step table, with bounds and H2's repairs |
| `stalled` | every delivery held past its bound, with the transition H2 may take |
| `stalled` | every delivery held past its bound, with the transition H2 may take, and every waiting walk no live delivery holds |
| `recheck` | a rejected or ready delivery back to proposed, with why |
| `release` | a held delivery to delivering, with why: a person's word |
| `stop` | any delivery not final to stopped, with why; its walk is ended through the controller |
@@ -191,7 +237,8 @@ up.
- Any other walk waits for `deliver` only while the seat has a holder on record. With none on record, the
controller starts it as it always did.
- A waiting walk is said by the controller whatever mesh-delivery says of itself: S16 raises
`plan.<walk>.waiting` after 30 minutes, urgent after 4 hours, naming `plans go <walk> --why` as the way on.
`plan.<walk>.waiting` after 30 minutes, urgent after 4 hours, naming the merges the walk answers and
`plans go <walk> --why` as the way on.
A holder that is down is also D3's `holder-silent`; one that is up and never says go is said by S16 alone.
## The switch
+176
View File
@@ -0,0 +1,176 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog modules/mesh-issues, mesh-sdk]
updated: 2026-10-10
decisions:
- 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
---
# 53 — The issue tracker
**The mesh keeps its issues in a module of its own, `mesh-issues`, one instance on the control-node, holding the seat
`issue-tracker`. It hands out every
issue's number from one counter that two callers can never both advance, holds each issue's status and links, refuses
a change the development cycle would refuse, and announces every change on the bus. hq keeps a folder only for a design
or governance issue, under the tracker's 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),
from [research 042](../../01-RESEARCH/042-the-mesh-keeps-its-own-issues/00-overview.md).)
An **issue** is a question about the mesh that someone must answer. The **issue tracker** is the module that holds
issues, named `mesh-issues`; the seat it holds is `issue-tracker`. The glossary holds both words.
**Acceptance.** An agent opens an issue and gets the next number; two agents opening at the same moment never get the
same one.
This document designs slice 1 in detail. The slices after it are listed in [§ 8](#8-the-slices-after-the-first),
each with what the operator sees when it is done.
## 1. The parts
```
control-node
mesh-issues (seat issue-tracker, held once for the mesh)
├ its process, launched by the node runtime
├ bucket issues one key per issue, by number; a short history per key
├ bucket counters one key per sequence; slice 1 has `issue` only
├ setting owners the registered list `located-in` may name
├ verbs open, get, list, move, note, link,
│ served by the control-node's tool runner
└ events opened, moved, noted, linked
any agent session, on any node
mesh MCP server ── mesh_search, mesh_describe, mesh_call ──▶ the verbs, over the bus
```
The module is written in Go against the SDK. It is built on the model of hits, a headless issue tracker on NATS, and
carries none of its code; its README says so.
## 2. The seat and its verbs
The module declares the seat `issue-tracker` at mesh scope and claims it exclusively
([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)): there is one tracker per mesh. Its verbs are
the seat's tools, served through the tool runner and announced on the bus
([ADR 0197](../../02-DECISIONS/0197-every-tool-announces-itself-on-the-bus-in-the-nats-services-protocol.md)), so an
agent finds them with `mesh_search` ("open an issue") and calls them as `issue-tracker.<verb>`.
| Verb | Takes | Does |
|---|---|---|
| `open` | a title, the report as text, optionally `located-in` and a folder path | checks the issue, then takes the next number and writes it; returns the number. Refuses an empty title or report, and an owner not on the registered list |
| `get` | a number | the issue whole: fields, notes, links |
| `list` | optionally a status, an owner, a phrase | compact rows, newest first, short enough to read in a chat: number, status, owner, title |
| `move` | a number, a status, and what that status needs | changes the status under the rules of § 5 |
| `note` | a number and a text | appends to the issue's notes; a note is never edited |
| `link` | a number, a kind and a target, or `remove` | adds or removes a typed link |
Each verb's description says in plain words what it does and what it refuses, without the mesh's internal names, so
an agent picks the right one from `mesh_search` alone. A refusal says what was wrong and what would be accepted.
**The caller is the bus credential, never an argument.** The runtime hands the module the identity of each call's
caller; the module records that identity as who acted. No verb takes an actor.
## 3. What one issue holds
- `number` (its key), `title`, `status` (`open`, `diagnosing`, `located`, `resolved`, `wontfix`), `opened`, `closed`;
- `located-in`: entries of the registered list of owners (repositories and modules), never free text;
- `fixed-by` (pull requests or commits), `amended-design`, `replay` or `replay-none`, and a reason for `wontfix`;
- `notes`: appended, each with who wrote it, when, and the text;
- `links`, typed: to another issue (`duplicates`, `relates-to`), a pull request, a decision record, a design, a
condition key or a delivery;
- `record`: the path of the issue's folder in hq, when it has one.
**The registered list of owners** is a setting of the module, changed through the controller's settings verb like any
other module setting. Slice 1 starts it with the repositories in hq's `00-META/repos.md` and the modules the catalogue
registers.
**An issue that has a folder in hq** keeps its status in that folder's frontmatter until the import of slice 3 has run
and been checked. Until then the tracker holds its number, title and `record`, takes its notes and links, and `move`
refuses it with the reason: its status lives in its folder. An issue without a folder is whole in the tracker from the
start. No status is written in two places for one issue.
## 4. State, the counter and events
**Buckets the module declares, the controller creates**
([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)).
The manifest's `state` names two buckets, `issues` and `counters`, with their options: `issues` keeps a short history
per key, `counters` one value per key, neither expires. The module never creates a bucket. One issue is one value; at
the 256 KiB a value may be, a note that would push an issue past it is refused with that reason (the largest issue
folder in hq is 25 KiB).
**The counter is advanced by compare-and-set.** Beside `get`, `put`, `delete`, `keys` and `watch`, the runtime's state
verbs hold two writes that are refused when another writer was first, in the runtime and the SDK's state interface
together ([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)):
- `create` writes a key only if it does not exist;
- `update` writes a key only if its revision is still the one the caller read, and returns the new revision.
The SDK's `Increment` reads a counter and writes the next number with `create` or `update` at the revision it read,
trying again a bounded number of times when another writer was first. The counter is started once, by `create` at
the number before the first, so it never starts twice. `open` then runs in this order:
1. validate the issue; a refused issue spends no number;
2. read `counters/issue` and its revision;
3. `update` it to the next number at that revision; if another caller moved it first, the write is refused, and
`open` reads again and retries, a bounded number of times;
4. `create` the issue's key; an existing key is a fault the module reports loudly and never overwrites.
So the counter does not rely on there being one instance: two instances, or two calls racing in one, still never hand
out one number twice. **The counter starts** above the highest issue number on `main` and on every open branch of hq
at the moment the module is switched on. Numbers already skipped stay unused, and a number never moves.
**Every change is an event the module declares** under `emits`
([ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md)): `opened`, `moved`, `noted`, `linked`. Each carries
the number, what changed, and who changed it, so the events are the audit trail, and anything watching the bucket or
the events sees a change as it happens. No event carries a secret, and an issue holds none.
## 5. The cycle's rules, at the write
`move` refuses what `cycle.py` refuses at merge today, and the rules playbook 03 states:
| The issue moves to | Needs |
|---|---|
| any status | one of the five |
| `located` | `located-in` names at least one registered owner |
| `resolved` | `located-in`, and `fixed-by` saying what fixed it |
| `resolved`, for 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) | `replay` (an id from 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)) |
| `wontfix` | a reason |
A refused move changes nothing and says which rule refused it. `cycle.py` keeps checking the folders that still carry
status until slice 3.
## 6. How the operator and agents use it
Through the mesh's tools first. An agent opening an issue calls `issue-tracker.open`, gets the number, and, for a design
or governance issue, writes `04-ISSUES/NNN-slug/` under it with the number in the report. Playbook 03's first step
changes to that the day slice 1 is live; nothing else in playbook 03 changes in slice 1. `list` answers "what is open"
in one call, from any session, and the responder on a channel answers from it once the seat exists.
## 7. How it is checked
| Rule | Checked by |
|---|---|
| Two opens at once never share a number | the module's test over a real bus: many concurrent `open` calls, from two instances, yield distinct consecutive numbers; the runtime's test that `update` at a stale revision is refused |
| A refused open spends no number | the module's test: an invalid open, then a valid one, gets the next number |
| An issue's key is never overwritten | the module's test: a `create` on an existing key is refused and reported |
| The cycle's rules hold at the write | the module's table test over § 5, one case per row, each refused case changing nothing |
| The module creates no bucket | the runtime refuses a module that tries (ADR 0201 §3); the controller's raise asserts both buckets |
| The caller is who acted | the module's test that the recorded actor is the call's identity, whatever the arguments say |
| Live | on the mesh, an agent calls `issue-tracker.open` and gets the next number; two sessions on two nodes open at the same moment and get two different numbers; `list` shows both |
## 8. The slices after the first
| Slice | Delivers | Done when the operator sees |
|---|---|---|
| 1 | The `mesh-issues` module: `open`, `get`, `list`, `move`, `note`, `link`; a counter by compare-and-set in the SDK; playbook 03 takes its numbers from it | an agent opens an issue and gets the next number; two agents opening at the same moment never get the same one |
| 2 | Decision and research numbers from the tracker, each its own key in `counters`; a number is reserved when a branch starts | two agents writing a decision at once get two numbers, and no hq pull request is renumbered |
| 3 | `import`: every issue folder in hq read through the records module and written as an issue with its own number, idempotent and showing what it would change before it does; the four consolidation passes of research 042 (fixed, duplicates, owners, grouping), each a list confirmed before it is applied; an audit of `fixed-by` against the forge; then one pull request removes the status keys from every report, changes playbook 03 and `cycle.py`, and adds dated notes to ADR 0019 and 0080 | the not-closed list comes from one call and shrinks by what was already fixed or duplicated, each with its reason |
| 4 | Conditions and pull requests linked: an issue links a condition by its key and the controller shows the link; a merged pull request notes every issue it names; after a week of that, the controller opens one issue per `healer-wanted` condition key by itself | a condition shows its issue, and a merge notes the issues it names |
| 5 | A read-only view onto the `issues` bucket over the bus | the operator reads issues in a browser without an agent |
Once slice 1 runs, each slice after it is opened as an issue in the tracker, so the tracker carries its own work.
## What it leaves out
Priorities, claims, notifications, full-text or semantic search, the forge's own issue lists, and an index of any kind:
none of them is in any slice above, and each needs a decision of its own.
+1
View File
@@ -52,6 +52,7 @@ document is written and this one's status becomes `implemented`.
| [`50-split-dns-on-a-machine-with-a-vpn-client.md`](50-split-dns-on-a-machine-with-a-vpn-client.md) | **In progress.** A machine whose VPN client pushes servers of its own runs a resolver of its own, on a node seat, that routes the VPN's domains over its link and every other name to the mesh's resolvers; the VPN client's module carries the adapter | [ADR 0247](../../02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md), [ADR 0223](../../02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md) |
| [`51-the-registries-keep-what-is-named.md`](51-the-registries-keep-what-is-named.md) | **In progress.** The artifact store, the package registry and each machine's container runtime get tools that say what they hold, what names each thing and what could go; every removal is a dry run unless a person asks and says why, and what decides is always a record | [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), [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md) |
| [`52-shares-and-mounts.md`](52-shares-and-mounts.md) | **In progress.** A node shares one folder at a time through `nfs-server`, which provides it as a provision to the private network only, mapping every client to the folder's owner; a node that asks mounts it through `mounts`, a mount unit and an automount per share, so nothing mounts or fails at boot; a device that comes and goes is an occasional source whose absence is a state | [ADR 0263](../../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md), [ADR 0092](../../02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md) |
| [`53-the-issue-tracker.md`](53-the-issue-tracker.md) | **Designed.** The mesh keeps its issues in one module, `issues`, on the control-node: every number from one counter advanced by compare-and-set, the cycle's issue rules refused at the write, an event per change; hq keeps a folder only for a design or governance issue | [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) |
## Not yet written
@@ -0,0 +1,63 @@
---
status: located
opened: 2026-10-08
located-in: [mesh-controller cmd/mesh-controller (upgrades.go, mergeCandidates and readsFrom), mesh-controller internal/inventory (dependencies.go, the packages edge), mesh-controller internal/builder (builder.go and source.go, a context's fingerprint), mesh-catalog modules/route-proxy and modules/build-agent (their recipes and build contexts)]
fixed-by:
amended-design:
---
# 338. A module built from a shared repository moves on every merge to it
## What was observed
On 2026-10-08 the controller's repository took eight merges. Every one of them produced the same
delivery plan: build `mesh-controller`, then `build-agent`, then `route-proxy`, in three tiers. Each
tier waited for its own first-node gate, about three and a half minutes each, so every merge of the
controller took three gates where one would have done.
`build-agent` (the build seat's holder) and `route-proxy` (the public route proxy on the home server)
are packaged in the catalogue, but their recipes compile code from the controller's repository. Their
manifests say so with a build context: `build.artifacts[].context` names the git seat, the repository
`novox/mesh-controller` and the ref `main`.
The plan rebuilt and moved them whatever the merge changed. Two examples:
- Mesh-controller PR #159, merged as `ca09a07f`, changed files under `cmd/mesh-controller`,
`internal/broker`, `internal/link`, `internal/catalogue/verbs.go` and the root `module.json`. Nothing
under `examples/route-proxy` changed. Its walk said, on the home server, "recreates 1 of route-proxy's
3 container(s) (with a new image: server)" and "recreates 1 of build-agent's 1 container(s)".
- Mesh-controller PR #163, merged as `9b028b42`, did the same. The route proxy's program at that commit
is byte for byte the program at the commit before it (measured in the diagnosis), and it was still
sent a new image and restarted.
The controller's `plans` what-if shows that the files do not matter. A merge that changes only the
controller's `README.md` plans the same three modules in three tiers:
> a merge of novox/mesh-controller would build 3 module(s) in 3 tier(s) … build-agent, route-proxy
> package source from novox/mesh-controller, so they are rebuilt without their own source moving
## What the operator expects
The operator stated the rule the delivery plan should follow:
1. Changed files map to the module or modules whose declared build source contains them. One commit can
touch several modules.
2. The module graph's build dependencies add the modules that are built from a changed module.
3. Nothing else counts: not sharing a repository, and not a whole-repository build context.
## Why it is an issue
- **Deliveries are slow.** A controller merge walked for about fifteen minutes instead of about five,
and the walks of one repository must not overlap (issue 335), so every merge after it waits too. The
operator considers this a major bug and a main reason deliveries are slow.
- **A public service restarts for nothing.** The route proxy on the home server was recreated at
merges that did not change its program.
- **The planner says it is path-precise and is not.** ADR 0238 says a changed file touches exactly the
modules whose build reads it. That holds for a module's own directory and fails for a build context,
which is read whole.
## How it is checked
Not checked today. The diagnosis proposes the checks that would hold the fix: a planner test in which a
change to a file outside a module's declared source paths moves nothing, and the `plans` what-if above
answering one module for a `README.md`-only change.
@@ -0,0 +1,195 @@
# 338. Diagnosis
## 2026-10-08 — the record first
`records_search` for "route-proxy rebuilt" found nothing. "build context" found
[ADR 0155](../../02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md) (a build
context may live on the git seat) and issue 060's note that the route proxy's build context is the
controller's repository, not its own module directory. "fingerprint" found issue 280 and
[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).
Issue 280 fixed the wide rebuild for a module's own directory and left one sentence that matters here:
the source fingerprint hashes "the git tree of each other repository an artifact's context is cloned
from", which is the whole repository.
## How a merge decides what it moves
Read in mesh-controller at `8170fc58`.
**Facts, from the code.**
1. `mergeCandidates` (cmd/mesh-controller/upgrades.go) sorts every catalogued module into two kinds.
A module whose own source is the merged repository and branch is a candidate *built from* it. A
module whose last build read the merged repository as a context is a *packaging* module
(`readsFrom`). The test for the second kind is `sameRepository` and the ref, and nothing else.
2. `touchedBy`, the one function ADR 0238 names for mapping changed files onto modules, is applied
only to the first kind. A candidate is touched when a changed file lies inside its module directory,
or when it is built from the repository's root. The packaging modules never pass through it.
3. `reachOfMerge` and the merge handler `SourceMoved` both add every packaging module to what moves:
`moved := touched + packaging`. So for a packaging module the rule is **per repository**, not per
path. The changed paths and `module_dirs_said` of PR #159's delivery were read, and they decided
nothing for `route-proxy` and `build-agent`.
4. `mesh-controller` itself is built from the repository's root (its manifest's bundle compiles
`cmd/mesh-controller`; its source path is empty), so every changed file in the repository touches
it as well.
5. The tiers come from `inventory.Dependencies`. Two of its edge kinds make the walk serial here:
- a `packages` edge, from a module whose build read a repository to the module whose source that
repository is. So `route-proxy` and `build-agent` both depend on `mesh-controller`, because they
share its repository, not because either is built from the controller's artifacts;
- a `built-by` edge, from every source-built module to the build seat's holder. So `route-proxy`
waits for `build-agent`.
That gives three tiers, each with its own first-node gate.
6. The manifests (mesh-catalog `modules/route-proxy/module.json` and `modules/build-agent/module.json`)
declare the context as the whole repository: `{seat: git, repository: novox/mesh-controller, ref:
main}`. Nothing in a manifest can say which paths of it a build reads. Their recipes copy the whole
context (`COPY . .`) and compile one package from it: `./examples/route-proxy` and
`./cmd/mesh-builder`.
7. These are the only two manifests in the catalogue with a build context. Both name the controller's
repository. That repository is also the source of `mesh-controller`, so three modules share it.
**The operator's rule, against the code.** Rule 1 (a changed file maps to the modules whose declared
build source contains it) holds for a module's own directory in `touchedBy`, and is broken by fact 3:
a packaging module moves on any file. Rule 2 (the graph's build dependencies add what is built from a
changed module) is broken by fact 5: the `packages` edge treats sharing a repository as a build
dependency. Rule 3 (nothing else counts) is broken by both. A manifest has no way to declare the build
source inside a context (fact 6), so the planner could not obey rule 1 for these modules even if it
tried.
## Why a rebuild of an unchanged program still moved
Issue 280's fix should make a rebuild of an unchanged source no move. It does not here, for two
reasons.
**The fingerprint reads the whole repository** (fact, internal/builder/builder.go and source.go). For
an artifact with a context, the builder clones the repository and records `git rev-parse HEAD^{tree}`,
the root tree. Any commit to the repository changes it, so two builds never share a fingerprint.
**The program embeds the commit** (fact, measured). The builder clones the context with its `.git`
directory, the controller's repository has no `.dockerignore`, and the recipes run `COPY . .` and then
`go build -trimpath` without `-buildvcs=false`. Go's default then stamps the commit and its time into
the binary. Built on the laptop with the recipe's flags at `8170fc58`, `go version -m` on the route
proxy shows `vcs.revision=8170fc58…` and `vcs.time=2026-10-08T19:48:03Z`. So every commit gives a
different program even where no source it compiles changed. The golang base image carries git, so I
expect the same stamp inside the build; I did not inspect a built image.
**And the image is not reproducible either.** Issue 280 already found that two `docker build`s of one
source give two digests. The build records confirm it: `build-agent` was built three times tonight
from catalogue commit `7b307a06` and got three different image digests.
## What actually changed in the two programs
To separate "the program changed" from "the plan moved it anyway", each program was built on the
laptop at each of tonight's merges, with `-trimpath -buildvcs=false`, and hashed.
| merge | route proxy program | build seat program |
|---|---|---|
| `3f68a495` | `41b7e844` | `262ef27d` |
| `0cf5a3a5` | `41b7e844` (same) | `17ae6be8` |
| `ac91357a` | `41b7e844` (same) | `323e4388` |
| `e3ec15f7` | `41b7e844` (same) | `9e0b21b0` |
| `7f65e627` | `41b7e844` (same) | `8825b46e` |
| `9b028b42` (#163) | `41b7e844` (same) | `062d8c27` |
| `ca09a07f` (#159) | `f0a5dda5` | `87b9e34e` |
| `efcdd5dd` (#161) | `57669ce3` | `9c9fc316` |
| `8170fc58` (#165) | `57669ce3` (same) | `9c9fc316` (same) |
The route proxy's program changed at two of nine merges. The build seat's program changed at eight of
nine. **This corrects part of the symptom as first reported**: PR #159 did change the route proxy's
program. The proxy imports `internal/broker`, `internal/lease` and `internal/envfile` from the
controller's repository, and #159 changed `internal/broker`. "Nothing under `examples/route-proxy`
changed" was true, and is the wrong test: a module's build source is its import closure, not its
directory. PR #163 (`9b028b42`) is the clean case: the route proxy's program was unchanged and it was
still recreated on the home server.
So a path-precise rule that looked only at `examples/route-proxy` would have missed #159's real
change. The build source of the route proxy is `examples/route-proxy`, `internal/broker`,
`internal/lease`, `internal/envfile`, `go.mod`, `go.sum` and `vendor/`.
## Measured: what the extra tiers cost
From `mesh-controller.plans` (read only), the first-node gate of each tier of tonight's controller
walks:
| walk | mesh-controller | build-agent | route-proxy | three gates |
|---|---|---|---|---|
| `3f68a495` | 3m40s | 3m43s | 3m46s | 11m09s |
| `0cf5a3a5` | 3m39s | 3m26s | 3m34s | 10m39s |
| `e3ec15f7` | 3m02s | 3m51s | 3m14s | 10m07s |
| `7f65e627` | 3m37s | 3m36s | 3m33s | 10m46s |
| `9b028b42` | 3m02s | 3m36s | 3m32s | 10m10s |
| `ca09a07f` | 3m43s | 3m46s | 3m40s | 11m09s |
| `efcdd5dd` | 3m38s | 3m40s | 3m37s | 10m55s |
Two more walks (`a5f53ef9`, `ac91357a`) were superseded part-way, and `8170fc58` was still walking at
21:50. The gates alone are 10 to 11 minutes per walk, of which 7 minutes are the two packaging tiers.
Each tier also builds before it sends: the build seat's image took 53 seconds at 21:36. Estimated from
the plan's creation time and the build records, `ca09a07f` walked from 21:07 to about 21:23 and
`efcdd5dd` from 21:32 to about 21:47, about 15 minutes each. The controller's own tier, alone, is about
4 to 5 minutes. Of the seven completed walks, five recreated `route-proxy` on the home server
(`3f68a495`, `e3ec15f7`, `7f65e627`, `9b028b42`, `ca09a07f`), and its program had changed in one of
them. Four recreated `build-agent` (`e3ec15f7`, `7f65e627`, `9b028b42`, `ca09a07f`).
## A second finding, not yet explained
The deploy plan names a build of a packaging module by the module's own source commit, which is the
catalogue's. `moveFacts.identical` (cmd/mesh-controller/release.go) calls two builds identical when
their commits are equal. In `efcdd5dd`'s walk both modules went `7b307a06 → 7b307a06`, nothing was
said to be recreated, and the gate still took about 3.5 minutes for each. Yet the route proxy's
program did change at `efcdd5dd` (table above). **I do not know whether that program reached the home
server.** Either it was sent and the plan said nothing, or it was not sent and the route proxy runs the
program from before #161. Next step: compare the image digest the home server's route proxy runs with
the digest of `build-1791487124940030405` and of the build before it. The same identity by catalogue
commit would also make issue 280's "repeat" rule register a different program as the same build.
## Ruled out
- **The changed paths not reaching the planner.** PR #159's delivery record carries its paths and
`module_dirs_said: true`. They are read, and `touchedBy` uses them for the controller's own module.
They are never consulted for a packaging module.
- **The catalogue moving at the same time.** In some walks the catalogue's commit also changed
(`32307bd1 → 7b307a06`), but the README-only what-if shows the controller's repository alone is enough
to plan both modules.
## Cause
Located. A module whose build reads a second repository as a context moves on **every** merge to that
repository, because `mergeCandidates` adds it by repository and never maps the changed files onto it;
the dependency graph's `packages` edge then orders it after the repository's own module, and `built-by`
orders the route proxy after the build seat, giving three gated tiers. A rebuild that changed nothing
still moves, because the context's fingerprint is the whole repository's tree and the Go build stamps
the commit into the program.
## Fix directions (not implemented)
1. **A manifest declares its build's source paths within a repository**, and the planner uses only
those, plus the module graph's build edges. For a context this means a field beside `repository`
and `ref`, such as a list of paths. `touchedBy` then treats a context module like any other: it moves
when a changed file is inside one of its paths. The `packages` edge goes, or is drawn only when a
changed path is inside the declared set. The builder fingerprints the git trees of those paths, not
the root tree, so a rebuild with an unchanged source is no move. Manifests that must change:
`route-proxy` (paths: `examples/route-proxy`, `internal/broker`, `internal/lease`,
`internal/envfile`, `go.mod`, `go.sum`, `vendor`) and `build-agent` (`cmd/mesh-builder` and its
twelve internal packages, the same three root files). They are the only two with a context today.
The controller's own manifest builds from the root and so also claims `examples/` and
`cmd/mesh-builder`; it should declare its paths too, or a change to the route proxy alone moves the
controller.
- **Tradeoff:** a hand-written list goes stale. When the route proxy starts importing another
internal package and nobody adds it, a real change is missed, which is worse than a needless
rebuild. The list therefore needs a check: the merge gate or the repository's own check compares
the declared paths with the program's import closure (`go list -deps`) and fails when the closure
reaches outside them. A derived alternative is to compute the closure at build time and record it
with the build; that is exact for Go and needs a reader per language.
2. **Reproducible builds**: `-buildvcs=false` (or a `.dockerignore` that leaves `.git` out of the
context) in both recipes, so an unchanged source gives an identical program. This alone does not
stop the walk: the image digest still differs (issue 280), and the planner would still plan and gate
three tiers. It is cheap and worth doing beside 1, so the program's identity no longer depends on the
commit.
3. **Split the route proxy and the build seat into repositories of their own.** Then the existing
per-directory rule already applies, with no new manifest field. The tradeoff: the route proxy and
the build seat share `internal/broker`, `internal/catalogue` and more with the controller. Splitting
means a shared library in the SDK (where the mesh's rule says shared code belongs) or copies of it,
and a change to that library then moves all three again, through a real build edge.
Recommendation: 1 with its check, and 2 beside it. A further question for the fix: whether `built-by`
should order a walk at all. It is a running-order edge for the build seat's worker (issue 206); the
operator's rule 2 counts only modules built *from* a changed module.
@@ -3,7 +3,7 @@ status: located
opened: 2026-10-09
located-in: [mesh-catalog (modules/claude-code), mesh-controller (internal/catalogue/terminal_keys.go)]
fixed-by:
amended-design:
amended-design: 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
---
# 340. An agent can plant a hook in every agent session on the mesh
@@ -76,6 +76,12 @@ own settings file. It does not see a tool call that changes the managed files of
operator's own home is not written from the tools (see "How it is checked"). In the operator's words: "once we build a UI, we
can also update the context through it". A surface of the operator's own, which can tell the operator from
an agent, is the intended way back.
> **Amended 2026-10-10 by [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).**
> Instruction sections at the mesh and node scopes gain a second way beside the terminal: an agent proposes one
> with `claude_code_instructions_propose`, and every node takes it only on the operator's warrant (an ask at the
> level approve, answered on a channel that proves who answered), which each node proves from the router's own
> record. The other kinds stay the terminal's, and approved sections never render from the bus, so the still-open
> item below is unchanged.
3. **Passwords are not settings.** They come from the key vault and can be rotated. Every module found taking
a password, token or API key through a setting is recorded in
[issue 342](../342-a-password-is-taken-as-a-setting-and-outranks-the-vaults/00-report.md), to move to
@@ -0,0 +1,102 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-controller cmd/mesh-controller (checks.go, the check request names the pull request's head as the ref), mesh-controller internal/builder (check.go, the check clones and checks out that ref and never merges the base), mesh-catalog modules/mesh-delivery (table.go, a ready delivery is rechecked only when a person asks or its group changes; holder.go, a merge on the trunk leaves the repository's other open deliveries as they were)]
fixed-by:
amended-design:
---
# 341. A merge check judges the pull request's head, not what merging it makes
## Symptom
On 2026-10-09 a node-engine pull request passed its merge check, and its merge broke the trunk. The
trunk then did not compile, so no build of it could be made.
What happened, in order (UTC):
1. **00:15.** Node-engine #64 and #65 (issue 339's follow-ups) merged. #65's merge commit, `d28ca95`, is
the node-engine build the nodes run. Both pull requests added a field `Applied.Declared` to the
node-engine's store record, a string, under the JSON key `declared`.
2. **00:49.** Node-engine #66 (ADR 0269) was checked, at head `dfb1415`. That check was asked after the
trunk had moved, so the check was not out of date by the clock. `mesh/merge-gate` passed ("4 of 4
compose") and `mesh/repo-check` passed (its `merge-check.sh`, every package `ok`). Read with
`mesh-delivery.checks` for the commit: both statuses `success`, set at 00:49:42, and "mergeable — every
status main requires is a success".
3. **00:54.** #66 merged, as `690966d`. #66 had added its own `Applied.Declared` field, a `*FoundUnit`,
**under the same JSON key `declared`**. The two edits touched different lines, so git merged them
without a conflict. The result does not compile:
`internal/store/store.go:145:2: Declared redeclared`.
4. **00:55.** The walk the merge started (`plan-1791507264215942736`) failed: "`mesh-host` failed to build
in tier 0". `mesh/delivery` on the head reads `failure`. Nothing was sent to a node, and every node kept
`d28ca95`.
5. **01:09.** Node-engine #67 repaired the trunk. It renamed #66's field to `DeclaredUnit`, key
`declared_unit`, and added a test that pins both keys.
The facts behind the "different lines" claim, read from the node-engine's repository: `dfb1415` does not
contain `d28ca95`. Their common ancestor is `a2c2c35` (#63's merge). `dfb1415` holds one `Declared`
(store.go line 107), `d28ca95` holds the other (line 139), and `690966d` holds both (lines 107 and 145).
No node was harmed, because a walk builds before it sends. But the merge check said "this may merge" of
a merge that did not build, and both required statuses were green when it merged.
## The cost before this: checks that went stale
The same night, the session that merged the pull requests below had to ask for their checks again by
hand, or rebase a branch, before the verdict was right (as that session reported it). Each time, another
merge or the order of merges had changed what the check would see. What each pull request's own text
shows:
- **Controller #165** (issue 335). Its body says `mesh/repo-check` "fails on
`TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose` until #159 merges". The head held the
trunk of before #159. A check of the head alone stays red until the branch is rebased, even after #159
is on the trunk.
- **Controller #159** (issue 330). Node-engine #60's body says the controller's repository check "failed
on exactly this test without it": the check reads the node-engine's trunk beside the controller, so #159
waited for #60 to merge and then for a new check.
- **Controller #148**. It was rebased so that its check would run on the current trunk.
- **Controller #169 and catalogue #145** (ADR 0268), and **controller #171 and catalogue #147** (the zfs
module). Each pair names a merge order in its body ("#169 first", "Do not merge before mesh-controller
#171"). The second of each pair needed its check asked again once the first had merged.
In every case a person kept track of which verdicts the last merge had made stale. That is the work the
merge check exists to remove.
## Why it is a design issue
The merge check is the rule that "a change is judged before it merges"
([ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md)).
The forge requires its two statuses on the trunk
([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)
decision 5). A person and an agent both read "both green" as "this merge is safe". But the check judges
something else: the pull request's head, alone. Two pull requests that each pass can break the trunk
together, and a recheck after the trunk moved judges the same head and passes again.
Nothing says that a verdict stops counting when the trunk moves. The delivery's state table moves a ready
delivery back to proposed only when a person asks or its group changes
([ADR 0239](../../02-DECISIONS/0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md)).
A merge of another pull request into the same trunk changes neither.
## What is expected
- A merge check judges exactly what merging would make: the pull request's head merged into the current
trunk. The forge keeps no merge ref for a pull request (`git ls-remote` lists only
`refs/pull/<n>/head`), so the build seat makes that merge itself in its clone. A head that does not
merge cleanly is a failure that says so.
- A verdict is tied to the trunk commit it was judged against. When the trunk moves, every open pull
request's verdict into it is stale. The check either runs again by itself or is marked out of date (the
required statuses back to pending), so that a merge is never let through on a verdict for a trunk that
no longer exists.
- The two required statuses then mean "this merge is green", which is what everyone already reads them as.
## The records say the head
ADR 0237 decision 4 has the build seat check "the head". ADR 0238 decision 1 says "A commit not on the
trunk — a pull request's head, any branch — is only checked", and decision 2 computes the delivery plan
from "a repository, the branch it merges into, the commit at hand". Neither record says the head is merged
with its trunk first. **So the fix needs a decision record** that extends ADR 0237 §4 and ADR 0238 §1–§2
(what is checked is the merge of the head into the trunk at a named trunk commit) and ADR 0239's state
table (a trunk move makes a ready verdict stale). ADR 0238's publishing rule is not touched: a merge made
to check a pull request is still a commit off the trunk, only checked, never registered.
The diagnosis is in [01-diagnosis.md](01-diagnosis.md).
@@ -0,0 +1,102 @@
# 341 — Diagnosis
## 2026-10-09
**The records first.** `records_search` for "redeclared", "refs/pull" and "merged into its base" found
nothing. "recheck" found [issue 305](../305-a-recheck-left-the-old-verdict-standing/00-report.md), which
is close but not the same. 305 made a recheck put both statuses back to pending before the new check
runs. It also recorded that "some of the rechecked pull requests turned red: judged against the trunk of
that day, they no longer composed". So stale verdicts were seen on 2026-10-07 too. 305 fixed how a
recheck is asked; it did not change what a check judges or when a verdict goes stale.
### What the check builds (mesh-controller at `36008e0`)
- `cmd/mesh-controller/checks.go`, around line 259: the build request for a pull request's check sets
`Ref: p.Commit`, the pull request's head. The trunk goes in as `Check.Base`, a branch name.
- `internal/builder/check.go`, line 52, the check's own description: it "clones the repository at the
pull request's head".
- `internal/builder/check.go`, lines 249–256 and 265: `clone` runs `git clone` and then
`git checkout <ref>` with `spec.Ref`. Nothing merges the base into that tree.
- `spec.Base` (declared at line 83, "what the gate compares the change against") is read only to pick
the check script from the trunk (`TheCheckThatJudges`, line 270, issue 310's fix) and to show the
touched manifests as they are on the trunk (around line 582). Neither builds the trunk with the change.
- A delivery group's other heads (lines 297–310) are cloned beside the head, each at its own head. They
are composed together for the merge gate. The repository's own `merge-check.sh` still runs on the head
alone.
So `mesh/repo-check` ran `go test` on `dfb1415`, a tree with one `Declared`, and passed. The tree that
merging made, with two, was never built before the merge.
### When a verdict goes stale (mesh-catalog at `63de373`)
- `modules/mesh-delivery/cmd/mesh-delivery/table.go`, line 60 and line 167: `EvRecheck` takes a rejected
or ready delivery back to proposed. Its comment says "a person asked for it again, or its group
changed".
- `holder.go`, `PullMerged` (line 347): a merge records the merge commit on that pull request's own
delivery and links its walk. It does not touch the repository's other open deliveries into the same
trunk.
- The forge marks a head pending only when it announces a new head (issue 305). A merge into the trunk is
not a new head of any other pull request.
So after #64/#65 merged, #66's verdict was not marked stale by anything. The recheck at 00:49 was asked by
a person. It judged the same head and passed again.
### What the records decided
- [ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md)
decision 4: the controller "asks the build seat a check: the head, and beside it the controller the mesh
runs and its main, the catalogue, the node-engine it runs, and mesh-lab".
- [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)
decision 1: "A commit not on the trunk — a pull request's head, any branch — is only checked".
Decision 2: a delivery plan is computed from "a repository, the branch it merges into, the commit at
hand".
- [ADR 0239](../../02-DECISIONS/0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md)'s
state table: `ready → recheck → proposed` when "a person asked, or its group changed".
The code does what the records say. The records name the head and never the merge, and say nothing of
a verdict going stale when the trunk moves. **This is a design gap, and its fix needs a decision record**
(playbook 02), not only a code change.
### Ruled out
- **A flaky test or a toolchain difference.** The failure is a compile error that exists only in the
merge. `dfb1415` and `d28ca95` each compile; `690966d` does not.
- **A check run before the trunk moved.** The check that let #66 merge was set at 00:49:42, 34 minutes
after #64 and #65 merged.
- **The forge merging badly.** git merged the two edits without a conflict because they touch different
lines. That is correct for git. A semantic conflict like this one is found only by building the merge.
### Owners
- **mesh-controller**: the check request and the builder's clone. The check builds the merge of the head
into the trunk commit it names, and says that trunk commit with its verdict.
- **mesh-catalog modules/mesh-delivery**: a trunk move makes the ready and rejected verdicts of the
repository's other open deliveries into that trunk stale. They are proposed again, or held out of date,
with the statuses back to pending (issue 305's path).
- **The forge's module** (mesh-catalog modules/gitea), possibly: if the fix chooses the forge's own
"outdated branch" protection instead, the protection is set there.
### Directions for the decision (not chosen here)
1. **The build seat merges, the delivery owner rechecks on a trunk move.** The check clones the trunk at
a named commit, merges the head into it (refusing a conflict), and runs both layers on that tree. Every
merge into a trunk proposes the open deliveries into it again. Cost: one check per open pull request
into that trunk per merge, on the build seat. How many that is on a busy night is not measured yet.
2. **The forge refuses an outdated branch.** Gitea's branch protection can block a merge when the pull
request's branch is behind its base. The check then stays a check of the head, and the head always
contains the trunk. Cost: every merge forces every other open pull request to rebase or merge the
trunk, by a person or an agent, and each of those is a new head and a new check. That is the manual
work the report lists, made mandatory.
3. **Both**: the merge is what is checked, and a verdict carries the trunk commit it was judged against.
The merge is refused while that commit is not the trunk's current one, until the automatic recheck
answers.
A delivery group already composes its members' heads for the merge gate. Whatever is chosen should say
how a group's composed check meets the trunk: merged into it, member by member in the group's order.
### Replay
This is a core issue (its `located-in` names the controller). It resolves with a replay in mesh-lab's
register, or a `replay-none:` saying why none is possible. A replay is possible: two branches off one
trunk commit, each adding the same field to one struct, each passing alone, and the second checked after
the first merged. Before the fix the second passes; after it, the second fails with the compile error.
@@ -23,8 +23,8 @@ The same day, read through the mesh's tools: the controller runs on the control-
**The records name a command that does not exist.** Issue 339, issue 340, ADR 0266 (hq pull request 225, not yet
merged) and the agent module's README say that certain changes are made "at the controller's terminal" alone, and
some give the line to type, such as `mesh-controller settings set claude-code '<json>'` and
`mesh-controller node agent-account <node> <account>`. None of them says how the operator reaches that terminal, and
some give the line to type, such as "`mesh-controller settings set claude-code '<json>'`" and
"`mesh-controller node agent-account <node> <account>`". None of them says how the operator reaches that terminal, and
nothing in the mesh provides it:
- The controller is a mesh process. The node-engine unpacks its bundle under the mesh's own directory and writes a
@@ -58,12 +58,12 @@ Four repositories, in the order they merge:
and runs the line through `runVerb`'s path, without `MESH_VERB` for the terminal and with
`MESH_VERB=mesh-cli` otherwise, after the `command` verb's refusals.
4. **mesh-catalog:** the module `mesh-cli` (its program, its archive into `/usr/local/bin`, its zsh alias `nox`),
assigned to every node; and the agent module's README, whose `mesh-controller settings set …` becomes
assigned to every node; and the agent module's README, whose "`mesh-controller settings set …`" becomes
`mesh-cli settings set …`.
Each code change is a branch and a pull request, merged by the operator after ADR 0272 is accepted. The records
that tell the operator to type `mesh-controller <command>` at the terminal mean `mesh-cli <command>` on the
control-node once phase 1 is live; ADR 0266's `mesh-controller node agent-account` line is changed on its own
control-node once phase 1 is live; ADR 0266's "`mesh-controller node agent-account`" line is changed on its own
branch, which is not yet merged.
## Later
@@ -100,6 +100,14 @@ note. Each gap says what is open, why it matters, and what would close it.
*Closed* on 2026-10-09 by mesh-controller #177: the build-source check judges the first matching rule, as the
forge does.
14. **Ways to root the node-engine's judge does not see.** ADR 0266 §3 lists them under *not judged, so not seen*:
among them a root-run unit or script the agent account can write, a directory on root's `PATH` it can write,
root's ssh keys readable by it, and a setuid program on a filesystem the search does not walk. None is known
on the control-node, but a healthy verdict says nothing about them. The record left them "for a later record"
with nothing tracking it; added here on 2026-10-10.
*Closes with:* each way judged by the node-engine beside the others, or a record that decides against judging
it and says why.
## Why it is an issue
Each gap is a known way around a rule the mesh states. A gap that is written down can be weighed and closed. A
@@ -0,0 +1,53 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-catalog modules/dnsmasq, mesh-controller cmd/mesh-controller (gate.go, machine_network.go, probes.go), mesh-controller internal/conditions, mesh-catalog modules/docker, mesh-catalog modules/netcheck]
fixed-by:
amended-design:
---
# 348. A resolver that stopped answering ran on unnoticed, and builds sent after it were blamed for it
## Symptom
On 2026-10-09, from 10:57:57 to 11:18:12 UTC, the mesh's resolver on the control node refused every
question asked on its private address. Its unit (`dnsmasq.service`, module `dnsmasq`, seat
`mesh-dns-resolver`) stayed active and running the whole time, and its journal said nothing. Every
question to that address got "connection refused". Loopback still answered.
What it cost:
- The certificate authority (`step-ca`, on the machine's own network stack) failed its health check from 10:59:37 UTC.
- The route proxy logged "lookup <machine>.internal on <private address>:53: … connection refused" for
every proxied request to a machine name, for twenty minutes.
- The control node's own network check failed (`machine.<control node>.network`, raised 10:58:45 UTC).
- The controller (build 5438d7ff) and the node-engine (build 8f763ee9), sent to their first machines at
10:59:34 UTC, failed their gate at 11:10:29 UTC with "raised since it was sent:
machine.<control node>.network" and were put back. The fault began before they were sent.
- The second resolver of the mesh, on the home server, did not take over the route proxy's lookups.
- D2, the controller's probe that every resolver answers, raised the resolver only at 11:06:00 UTC, eight
minutes after it stopped answering.
- A person restarted the unit at 11:18:12 UTC by hand, and that fixed it. Nothing in the mesh would have.
Five seconds before it stopped answering, at 10:57:52 UTC, a merge check on the control node removed its
throwaway containers, and the runtime tore down their bridge network and its virtual links.
## Why it is an issue
Three rules of the mesh did not hold.
- **Failure must be loud** (ADR 0240). A module is judged healthy when its unit runs. Nothing asked the
resolver a question. A resolver that answers nothing on the address every machine and container uses
read as healthy for twenty minutes.
- **A send is judged on what it caused** (ADR 0236, issue 281). The gate failed two builds for a fault that
began before they were sent.
- **Nothing restarts on health; a healer that does is its own record** (ADR 0240 rule 6, ADR 0231). No
healer and no module repairs a resolver that stops answering, so the only repair was a person's.
The operator's acceptance for the fix: if the mesh's name service stops answering on a node, the mesh
notices within a minute or two, restarts it by itself, and tells the operator only if that did not help.
A build is never put back for a problem that started before it was sent.
The diagnosis is in [`01-diagnosis.md`](01-diagnosis.md).
The fixes are in review: novox/mesh-controller pull request 179 and novox/mesh-catalog pull request 161.
@@ -0,0 +1,187 @@
# 348. Diagnosis
## 2026-10-09
**The record.** A search of the records for "bind-dynamic", "raised since it was sent" and "dnsmasq"
found issue 110 (why the resolver is told an address and not an interface), issue 281 (a machine's
condition read as a module's), issue 277 (D2 asks again before it says anything) and research 032's
evidence that the runtime's event history is about a minute long. None of them is about a resolver that
stops answering while it runs.
### Timeline (UTC)
| time | what was seen | where |
|---|---|---|
| 10:57:52 | a merge check's throwaway containers removed, their bridge network and virtual links torn down | the runtime, on the control node |
| 10:57:57 | the route proxy's first "lookup … on <private address>:53: … connection refused" | the route proxy's log |
| 10:58:36 | `machine.<home server>.network` raised: one of its two resolvers does not answer | conditions |
| 10:58:45 | `machine.<control node>.network` raised; the home server's cleared, held under it | conditions |
| 10:59:34 | the controller 5438d7ff and the node-engine 8f763ee9 sent to their first machines | the walks |
| 10:59:37 | `module.step-ca.<control node>.unhealthy` raised | conditions |
| 10:59:42 | `machine.<control node>.network` **cleared**, its newest evidence (10:59:37) still "connection refused" | conditions |
| 11:00:23 | `machine.<control node>.network` reopened, raised time 11:00:23 | conditions |
| 11:06:00 | `seat.mesh-dns-resolver.<control node>.wrong` raised by D2 | conditions |
| 11:10:29 | both builds failed their gate: "<control node> as a whole: raised since it was sent: machine.<control node>.network" — put back | conditions |
| 11:18:12 | `dnsmasq.service` restarted by hand; every question answered again | the operator |
| 11:21:46 | `machine.<each>.core-behind` raised: "… and no plan is rolling them out", while a release walked the node-engine | conditions |
### What is measured, and what is guessed
**Measured: the resolver ran and did not answer.** The unit stayed active; the journal said nothing; a
restart fixed it. Every question to the private address on port 53 got "connection refused" — the
kernel's answer when no socket is bound there. Loopback answered throughout. So dnsmasq had closed, or
lost, its socket on the private address and kept the one on loopback.
**Guessed, then tested: the bridge teardown closed it.** The configuration says `bind-dynamic` and two
`listen-address` lines. With `bind-dynamic`, dnsmasq binds one socket per address and re-reads the
machine's addresses on every netlink change, closing what it finds gone. The teardown five seconds before
is a burst of exactly those changes. The reproduction is in the section below: the mechanism is measured,
the twenty-minute loss is not.
**Measured: why the gate blamed the builds.** The gate counts a condition against a send when it was
raised after the send (`judgeHealth`, `aboutTheMachine` in the controller's `gate.go`). The comparison
was right; the raised time was not the fault's. The control node's network condition cleared at 10:59:42
although its newest evidence still said "connection refused", and reopened at 11:00:23 with a new raised
time — after the send. The node-engine sent at 10:59:34 restarted, and a node-engine just started judges
each part of the network again from nothing: its first statement said the names part `unknown`, "one look
failed; a second decides" (`mesh-host` `internal/network`, `Look`). The controller clears every network
condition a statement no longer says unhealthy (`judgeNetworks`), so the restarted engine's first,
undecided statement cleared it, and its second look raised it anew. A reopening within ten minutes is the
same fault by the conditions' own rule (`ReopenWithin`), but a reopening took the new raised time.
**Measured: why D2 took eight minutes.** The self-check runs every five minutes. D2 holds a resolver that
answered none of its questions for the next run (`Confirm`, issue 277), because on a loaded machine a
question can be lost. Its first run after 10:57:57 held it; the next, at 11:06:00, raised it. Every one
of those questions had been refused, which no load explains.
**Measured: why the second walk of the node-engine waited on its own first machine.** A release keeps no
record per module — its modules are in its tier only — and D10 read only the records to know what a plan
is rolling out. So while a release walked the node-engine at 11:21, D10 raised `core-behind` on every
machine, "and no plan is rolling them out", and the gate on the home server, its first machine, waited
on it (issue 281's settle window) until D10's next run five minutes later found the new engine there.
The walk then passed. It did not fail, but it waited five minutes for a condition its own send caused.
**Not explained: why the second resolver did not save the lookups.** Every machine's resolver file lists
both resolvers (`options timeout:1 attempts:2`), and the control node's own file lists its private address
first and the home server's second (read with `node-uplink.resolvers`). The control node's node-engine
reached the second resolver: its condition said one of two resolvers did not answer. The route proxy runs
on the machine's own network stack and resolves with Go's own resolver, which tries every listed server and, when all
fail, names the **last** one it tried (Go `net/dnsclient_unix.go`, `tryOneName`). Its error named the
private address of the control node, the first one. I think the route proxy's copy of the resolver file
lists only the control node's own resolver, because Go would otherwise have asked the second and named it.
That copy is the runtime's, made when the container was created (08:04 UTC that day), and no tool could
read it: it is root's, and the docker module had no way to show it. Next step: the new `docker_resolv_conf`
tool (below) reads it, on the next look after it is delivered. Whether the certificate authority's failure
has the same cause is not known.
**Measured: why `docker_events` returned nothing for 180 minutes.** The runtime keeps about its last 256
events in memory, and the containers' own health checks fill them within minutes (research 032). Asked at
11:24 UTC, the tool returned 254 events from the last ten minutes only. The teardown at 10:57:52 had already
been pushed out when the question was asked. The tool answered "0 events" as though nothing had happened.
### The bind behaviour
Reproduced on the laptop only, in a user and network namespace (never on a machine of the mesh): a dummy
link holding the private address, the module's configuration with `bind-dynamic` and both
`listen-address` lines, dnsmasq 2.93 in the foreground, a client asking the machine's name on both
addresses every 0.1 s, and a bridge with 4 to 80 virtual link pairs brought up and deleted, one by one or
all at once.
**Measured.** Every few teardowns dnsmasq closed both listeners ("stopped listening on … port 53", at
debug level only, so never in a normal journal). A build with two lines of logging added showed each
closing follows a failed reading of the machine's addresses: "iface_allowed: index N gone (No such
device)", N a virtual link being deleted — 59 failed readings and 53 closings in 120 teardowns of 8 links
at once; 107 and 104 in 20 teardowns of 80. In every trial the next address change re-bound them within
the same burst: two seconds after a teardown the private address was unbound 0 times of 180, and the
client was never refused.
**Read in the source** (dnsmasq 2.93, `src/`): `iface_allowed` returns 0 when a link's index no longer
resolves (`network.c:255-259`); that fails the whole enumeration (`netlink.c:225`, `256-258`, `297-301`);
a failed IPv6 pass skips the IPv4 pass, so no IPv4 address is marked found (`network.c:832-840`); under
`bind-dynamic` alone, every listener not found is closed (`network.c:849-875`); and one is bound again only
by a later netlink address event followed by a clean reading (`network.c:1177-1190`, `netlink.c:406-411`).
**Guessed.** That on the control node the closing came with no later address event to re-bind it — the
netlink socket overflowing in the burst and dropping the events after it (it is read without blocking,
and what overflowed is lost), or no address changing on the machine for twenty minutes. Why loopback kept
its listener there is not known; a reading that failed at a link ordered after loopback and before the
private network's would close only the private address.
**Tested alternatives.** `bind-interfaces` closed nothing under the same churn, kept answering after the
private address and its link were deleted and made again, and answered a query from another namespace
through a bridge (issue 110's case). It refuses to start when the address is not there yet. `interface=`
lines with `bind-dynamic` closed listeners as before (14 in one run). So the configuration now says
`bind-interfaces`, and a drop-in makes the unit wait up to 80 seconds for the private address before it
starts and retry every ten seconds, rather than leave it failed. The watch stays: it catches whatever else
stops the resolver answering.
## The fixes
One pull request per repository, each naming this issue.
**mesh-catalog — the resolver binds once.** `bind-interfaces` in place of `bind-dynamic`, and the unit
waits for the private address (above).
**mesh-catalog — the resolver asks itself, and restarts itself.** The `dnsmasq` module is now Go (its
TypeScript tools were never served on any machine). Its new long-running process, the watch, runs as root
and every 15 seconds asks the machine's own name — from the roster — on every address the configuration
listens on, over UDP and TCP, and checks the answer is that machine's address. After two failing looks in
a row it restarts the unit — only one that runs: a unit stopped or disabled on purpose, still starting
(waiting at boot for the private address) or failed is the service manager's or a person's, and is held
and said — without waiting for the start, at most once every two minutes and three times an hour (a
budget it reads back when it restarts itself), and the looks after it
say whether the restart helped. Its tool `dnsmasq_health` is the watch's declared check (ADR 0240): it asks
again and reads what the watch did, so a restart that helped leaves the module healthy and the operator
hears nothing, and one that did not becomes `module.dnsmasq.<machine>.unhealthy`, saying "restarted at …,
which did not help". The unit itself is judged by its unit (`health: unit`). The bundle emits `restarted`
and, once the looks decide it, `restart-judged` (helped or not) on the bus beside the names it answers,
each remembered as said only once emitted. The repair is the module's own, as the identity provider repairs
its administrator (ADR 0224 §5), because a resolver that stops answering can cut its machine off from the
bus by name, and then nothing outside the machine reaches it. **This departs from ADR 0240 rule 6**
("nothing restarts on health"), which left a restart on health to a healer's own record under ADR 0231.
That record is not written; whether this module-local repair stands, or becomes a row of the healer
registry, is the operator's decision.
**mesh-controller — a fault is judged by whether it was there at the send.**
- A condition reopened within `ReopenWithin` keeps when the fault was first raised (`first`) and its
gaps, each from a clearing to the reopening after it, across a controller restart too (from the
history). The gate asks `OpenAt(send)`: the fault began at or before the send and the send fell in none
of its gaps. So a fault there at the send that flapped after it is not the send's, and a machine that
had recovered before the send and broke after it fails the gate, as it must (review of the controller's
pull request: a silent pass is worse than a false blame).
- An undecided part of a network statement — `unknown` or `starting`, as a node-engine just started says
— holds the open network conditions whose newest evidence names that part, and only those. A statement
undecided as a whole holds every part. The next statement decides.
- D10 counts what a release's tier names as rolling out, so a walked node-engine is not `core-behind` on
its own first machine.
- D2 is unchanged: a resolver that answered nothing, refused or silent, is held for the next run, because
a few hundred milliseconds of refusals is a resolver restarting as well as one that stopped. D2 says it
five to ten minutes after; the node-engine's own network check is the fast one (48 seconds on the day).
**mesh-catalog — the tools that were missing.**
- `netcheck_dns` asks one named resolver directly (`server`, `tcp`), and `netcheck_listening` lists what
listens on the machine (`ss -lunt`). netcheck is assigned on the home server and the laptop only;
assigning it on the control node and the workstation is the operator's.
- `docker_events` reads the runtime's whole buffer of events: the window is complete when the buffer is
not full (it then holds every event since the runtime started) or reaches past the window's start, and
otherwise says where the history begins, instead of answering an empty list.
- `docker_resolv_conf` reads a container's resolver file as the container sees it.
## How it is checked
| Rule | Check |
|---|---|
| A fault there at the send fails no gate; one recovered at the send and back after it does | controller `TestReplay348` (the day's statements: raised, an undecided restart, raised again; the gate on a send between), `TestAFaultThatFlappedAfterTheSendIsNotTheSendsAndOneThatRecoveredBeforeItIs`, conditions `TestAReopeningKeepsWhenTheFaultBegan`, `TestAFaultKeepsItsNewestGapsAndForgetsWhatWasBefore` |
| An undecided part holds only what names it | `TestReplay348`, `TestAnUndecidedPartHoldsOnlyWhatNamesIt` |
| A release's modules are rolling | `TestAReleaseRollsOutWhatItsTierNames` |
| The resolver is asked a real question, restarted, and said when it did not help | dnsmasq `TestALookFindsAResolverThatStoppedAnsweringOnOneAddress`, `TestTheWatchRestartsOnASecondFailingLookWithinItsBudget`, `TestTheWatchRestartsAResolverThatStoppedAnsweringAndHealthSaysWhetherItHelped`, `TestTheWatchRestartsOnlyAUnitThatRuns`, `TestTheWatchLoopKeepsItsBudgetAcrossItsOwnRestart`, `TestHealthSaysWhenTheWatchIsNotLookingOrTheUnitIsNotRunning`, `TestTheAnnouncerSaysWhatChangedAndRemembersOnlyWhatWasSaid` |
| Every changed condition is tested | the controller's and the modules' changes were mutated one at a time (28 in the controller, 31 in the modules); each failed a test |
| live | after delivery: `dnsmasq_health` on both resolvers healthy; a restart by the watch said as `restarted` on the bus |
A replay in mesh-lab's register is still owed before this core issue resolves (ADR 0237).
The controller's own test of the resolver's manifest (`TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf`)
reads the catalogue beside it, so for now it takes either binding; once the catalogue's main binds once,
it should refuse `bind-dynamic` (a follow-up in the controller).
@@ -0,0 +1,72 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-controller cmd/mesh-controller (release_plan.go supersededBy, upgrades.go), mesh-controller internal/inventory (release_plan)]
fixed-by:
amended-design:
---
# 349. A merge acted on late superseded the merge after it, and built the older commit
## Symptom
On 2026-10-09 the plans of the catalogue superseded each other out of the branch's order, twice
(`mesh-controller.plans`):
- the plan at 1833045c was "superseded at tier 0 by" the plan at a783ac94, a merge made before it;
- the plan at a082615b — the merge of a security fix to the forge's module — was "superseded at tier 0 by"
the plan at 8ff8197a, the merge before it, with "gitea planned there again". The forge's module was then
to be built from the commit without the fix. A third merge (9e4267bf) came a minute later and superseded
that plan in turn, so the fix was built from 9e4267bf. Had nothing come after, it would not have been.
## Why it is an issue
A newer plan of a branch supersedes the older open ones and builds what they had not
([ADR 0218](../../02-DECISIONS/0218-a-plan-sends-grants-before-code-rolls-out-one-machine-first-and-a-newer-merge-takes-over-an-older-plan.md),
issue 254). "Newer" was read from when each plan was made, never from the commits. The catch-up acts on a
merge the bus did not hand over (issue 266) after the merges that came after it, so its plan was made last
and won. The mesh built an older commit than the branch held, and said nothing wrong: each plan said
"superseded", as designed.
## Diagnosis
The order of merges into one branch is the forge's: each merge is made on the branch as the one before
left it, so a later merge's commit contains every earlier one's, and the forge's merge time
(`merged_at`, which every announcement carries) is the branch's order. A plan kept none of it.
`supersededBy` compared `Created`.
## Fix
In mesh-controller, in the same pull request as issue 348 (PR 179):
- A plan keeps when its merge was made, to the nanosecond (`merged_at`, migration 0086; null for a release
and for a plan kept before it).
- `supersededBy` orders two plans of a branch by their merge times where both are known, and by when they
were made only where one is not.
- A merge older than the newest planned merge of its branch — that plan in any state, open or already done
— is planned at that merge's commit, which contains it, with that merge's time. A module the newer merge
already looked at then reads as history and is not built again; what only the older merge moved, and its
dependents, are built from the newest commit. Comparing with open plans alone left a gap the review
found: a late merge arriving after the newer plan was done rebuilt its dependents at the older commit.
That gap is closed.
- Not covered: a newer merge that moved nothing the mesh builds made no plan, so it is not known here; a
late older merge after it is then planned at its own commit, as before. Its modules are those the newer
merge did not change, so what it builds from the older commit is what the newer one holds too, unless a
dependent's build reads shared code the newer merge changed.
## How it is checked
`TestTwoMergesActedOnInReverseOrderBuildTheNewerCommit` replays the day's case: the later merge acted on
first, the earlier one second; one plan is left open, at the later commit, building what both moved.
`TestALateMergeAfterTheNewerPlanEndedBuildsTheNewerCommit` acts on the late merge after the newer plan is
done. `TestTheBranchOrderIsTheMerges` holds the order and the lookup, to the nanosecond. The first and the
last fail on the commit before the fix, and every condition of the change was mutated and caught.
A replay in mesh-lab's register is still owed before this core issue resolves (ADR 0237).
## A sequel (2026-10-10)
Planning an earlier merge at the later merge's commit left that earlier merge's delivery without a walk:
mesh-delivery matches a walk to the delivery merged as its commit, so two catalogue merges 18 seconds apart
left the earlier one held for a person and the walk carrying it waiting for a word nothing gave, for 58
minutes, with no condition raised. See
[issue 362](../362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person/00-report.md).
@@ -0,0 +1,66 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-catalog modules/mesh-delivery (holder.go, creditBacklog and carries)]
fixed-by: mesh-catalog #163
amended-design:
---
# 351. A held delivery whose module a backlog walk carried to every machine is never covered
## Symptom
On 2026-10-09 the catalogue's delivery of mesh-catalog #125 (the lighting module's account group,
[issue 316](../316-a-group-member-held-before-its-composed-check-answered-stayed-held-after-it-passed/00-report.md))
was still `held` 28 hours after its merge, raising `delivery.<id>.stalled` to the operator every hour.
mesh-delivery had run the build of [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)
(mesh-catalog #150) since 07:34 UTC that day, and its table (`mesh-delivery.table`) carried the `covered`
row. Seven other held deliveries ended `covered` in its first tick at 07:31 UTC, as the record promised.
This one did not, and the holder's journal held no line about it: the forge was never asked whether a
later merge contains it.
What the records say, in UTC (`mesh-delivery.show`, `mesh-controller.plans`, `hand-acts`, `module list`):
| time | what happened |
|---|---|
| 10-08 10:09 | #125 merged as a6385479 and held for its group's composed check (issue 316); its walk waited for its word |
| 10-08 11:06 | three later catalogue merges took its walk over in turn ([issue 317](../317-a-held-deliverys-module-was-walked-by-a-later-merges-plan/00-report.md)); the last of them, #123's at 86077780, built the module and sent it to the laptop |
| 10-08 11:21 | that gate failed: the module's unit was unhealthy on the laptop; the walk ended `failed`, then superseded |
| 10-08 14:27 | #127 merged as bf542128; its walk built and delivered its own three modules, `done` at 14:34; the module was built from bf542128 too, and its moves were held back for a person ([issue 318](../318-one-modules-wait-for-a-new-login-held-every-other-modules-walk/00-report.md)) |
| 10-08 14:43 | the operator released the held moves (`upgrade release-backlog`); a backlog walk sent the module to the laptop at 14:43 and the desktop at 14:46, judging each, and was `done` on both at 14:48 |
| 10-09 14:40 | `module list`: the module built from bf542128, on the laptop and the desktop; `status` lists it as not behind its source; #127's delivery shows the module on both machines as `judging — carried by` that backlog walk, as it had for 24 hours |
No merge into the catalogue's trunk since #125 touched the module's source, so no later merge will ever
move it again ([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)):
left as it is, the delivery stays held for ever.
## Why it is an issue
ADR 0270 covers a held merge when a later delivered merge's walk "sent the module to every machine running
it, as its machine steps say". The change that runs on both machines is #125's, carried by #127's merge
commit, which the forge says contains a6385479. The decision's question — does this change go on? — was
answered by the operator's own release of the build. Yet the record cannot end:
- **A backlog walk's end is read by nothing.** While it judges, the controller's record of it names what
its gate carried, and mesh-delivery credits the delivery that published each carried build one step per
machine. Its last record keeps no gate, only the machines it is done on, and mesh-delivery reads nothing
from that: the steps stay `judging` for ever.
- **`covered` reads only a walk's own aggregate steps.** A walk's own record says "every machine running
it" or "the rest running it"; a step per machine, which is all a backlog walk gives, never counts as the
module sent everywhere.
ADR 0270 stated one such gap, for a module that runs on one machine only. This is a wider one: any module
whose build reached its machines on the operator's word, after the mesh held it back, rather than by the
walk of the merge that built it. A held delivery in that case has no end but a person's wrong act
([issue 319](../319-a-held-delivery-a-later-commit-superseded-has-no-end-but-release/00-report.md)), and
the condition it raises every hour is one nobody can clear correctly.
## What was ruled out
- A forge that could not answer: the holder's journal has no "not yet covered" line for it, and
`gitea_contains` answers `contains: true` for a6385479 in both 86077780 and bf542128.
- A coverer forgotten: a final delivery is kept 30 days, and #127's is read back whole, with its steps.
- The walk that failed: #123's delivery is `failed` with the module's step `failed` on the laptop; it is
rightly no coverer.
- A person's `release` of #125 (the verb): it would walk a6385479, an older commit than the one the module
was built from, and is what ADR 0270 ruled out.
@@ -0,0 +1,73 @@
# 351 — diagnosis
**2026-10-09.** Read from mesh-delivery's code on the catalogue's main (`modules/mesh-delivery/cmd/mesh-delivery`,
at the build of mesh-catalog #150 and #160), `mesh-delivery.show` of #125, #123, #124 and #127,
`mesh-controller.delivery-walks` for #127's walk and for the backlog walk, `plans`, `hand-acts`, `module list`,
the forge's `gitea_contains`, and the holder's journal on the control node.
**Where a backlog walk's steps come from.** `walkMoved` (holder.go) hands a walk whose record carries a
`release` to `creditBacklog`, which reads the gate of its current record and, through `creditCarried`, gives
the delivery whose merge commit each carried build was made from one step per machine: `judging` while the
gate judges, `passed` or `failed` on its verdict. The controller's last record of a finished backlog walk
holds no gate and no modules: `state: done`, `release.done: [the laptop, the desktop]`, `note: released on …`.
`creditBacklog` reads nothing from `done`, so the two `judging` steps it credited to #127's delivery at 14:43
and 14:46 UTC were never moved on. (The controller's own record of #127's walk never named the module: the
module was not in that walk's tiers, and the backlog walk is a walk of its own.)
**Why `carries` refuses #127.** `coverOf` asks, for each module the held delivery's plan moves, whether a
later delivery `carries` it. `carries` reads the candidate's steps for the module and sets "everywhere" only
on a `sent` step whose machine is the walk's own aggregate, "every machine running it" or "the rest running
it". #127's steps for the module are two per-machine steps, `judging`, so `carries` is false, no candidate is
found, and the forge is never asked — which is why the journal has no line.
**What "done on a machine" means for a backlog walk.** Read from the controller's records, not its code: a
backlog walk that failed a gate ends `FAILED at tier … failed its gate on <machine>` and puts the builds back
(seen the same day for the mesh's own parts); one that ends `done` lists every machine in its order as done,
with "skipped: none reported". So a machine in `done` is one the gate passed on. That reading is a fact of
the records as they are; if the controller ever lists a machine as done without a gate's pass, this diagnosis
is wrong there.
**Ruled out.** A forge that could not answer, a coverer forgotten, the failed walk, and the `release` verb —
see the report.
**Located** in mesh-delivery: `creditBacklog`, which reads a finished backlog walk's `done` machines for
nothing, and `carries`, which counts no per-machine step.
## The fix
In mesh-catalog #163, one pull request, in `modules/mesh-delivery`:
- **A finished backlog walk passes the steps it carried.** `creditReleased`: every step credited to that
walk (`carried by <its id>`), on a machine it is done on, still `sent` or `judging`, moves to `passed`. A
step it failed on, and a step another walk carried, are left as they are.
- **`carries` reads per-machine steps against the held delivery's plan.** A module is sent everywhere when
the walk's own aggregate step says so, as before, or when a `passed` step stands on every machine the held
delivery's plan sends the module to (`machines[].receives`). A plan that names no machines, a machine still
judging, one failed or one never sent leave the delivery held.
- **A walk that carried a step still judging is asked for by its id.** The controller lists back only its
open walks and the last fifty ended at a start; the backlog walk of this case, ended the day before, was
no longer among them when read on 2026-10-09, so its end would never have been heard. The holder asks for
such a walk by its id (`delivery-walks` with `plan`), at a start at once and then once an hour, and takes
its record as said.
Nothing else in ADR 0270 changes: the later merge must still be delivered on its own passing check, by its own
walk of its merge commit on the same trunk, and the forge must still say that commit contains the held one's.
The record's consequence that named the one-machine gap gains a progressive insight naming this one.
**How it is checked**, in `covered_test.go`:
- `TestAModuleAReleaseCarriedToEveryMachineCovers`: passed on every machine the plan sends it to is covered;
still judging on one, failed on one, never sent to one, or a plan naming no machines stays held.
- `TestAFinishedReleaseMovesTheStepsItCarriedToPassed`: done on both passes both; done on one passes that one
and leaves the other judging; a failed step stays failed; a step another walk carried is not touched.
- `TestAHeldDeliveryWhoseModuleAReleaseCarriedIsCoveredOnceTheReleaseIsDone`: the day's case whole — held
while the backlog walk judges, covered once it is done and read by its id, naming the later merge and the
module.
All three fail on the catalogue's main before the change and pass after it.
**After the fix is delivered.** At the holder's first ticks with the new build, #127's two judging steps are
asked for by the backlog walk's id and pass; #127 then carries the module for #125; the forge is asked once
whether bf542128 contains a6385479, which it does; and #125 ends `covered` by #127. Its group then reads
delivered, and the stalled condition clears at the self-check's next reading. Whether that happened is the
acceptance of this issue, read with `mesh-delivery.show` of #125.
@@ -0,0 +1,46 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-controller cmd/mesh-controller (gate.go, release.go, release_plan.go, acting.go, push.go), mesh-controller internal/inventory, mesh-controller internal/catalogue]
fixed-by:
amended-design:
---
# 352. A gate read another send's report, and a put-back chose a controller older than its store
## Symptom
On 2026-10-09, local times on the control node (its controller's and node-engine's journals, and `plans`):
- 16:19 a release sent the control node three core builds — the controller (2913c54c), the node-engine
(d8ff1540) and the tool runner (83c19941). 16:21:43 the control node applied them; 16:21:53 its
node-engine said each was healthy. The release's gate did not pass in the nine minutes that followed.
- 16:30:56 a merge of the controller (e6b00e2e) made a plan; 16:31:37 that plan sent the control node the
newer controller — a newer send to the same machine, while the release's gate was judging there;
16:31:44 the serving controller was replaced by the newer one.
- 16:32:20 the new process judged the release's gate **failed**: "not healthy within 10m0s of its apply:
the control node has not reported on what it was sent" — and put all three builds back, on every
machine, in one send. The control node had reported them, ten minutes earlier.
- 16:32:24 the put-back controller (ef26d4cb) started and said it was behind its own row: it carried fewer
migrations than the store had applied, and served what it could.
- 16:35:59 that older controller judged the newer plan's gate **passed** on the control node ("healthy 3
times over 4m17s") — from the health of the build it was itself, not of the build the plan had sent,
which the put-back had removed from the machine. The plan walked on, believing the controller delivered.
## Why it is an issue
Four rules did not hold.
- **A gate judges its own send** (ADR 0236). The gate held the machine's report against the declaration
sent *last*, which another plan's send had just replaced; the report on the gate's own send read as
none. The report was never lost; it was compared with the wrong send.
- **A gate judges the build it sent.** The controller's gate read the lease holder's readiness and the
machine's report, and neither said *which* controller build was running. A put-back build passed the
gate of the build it had replaced.
- **A put-back puts back something that can run the mesh** (ADR 0236: "put back by something other than
itself"). A controller older than the store's schema starts behind its own records. Nothing refused it.
- **One walk of a module on a machine at a time** (issue 281, ADR 0236). A release's walk was invisible to
the merge plan's send: a release keeps no module records, and the check for a walk in progress read
only those.
The diagnosis and the fix are in [`01-diagnosis.md`](01-diagnosis.md).
@@ -0,0 +1,86 @@
# 352. Diagnosis
## 2026-10-09
**The record.** A search of the records for "has not reported on what it was sent" and "older than the
store" found nothing. Issue 335 (a passed gate failed by later reports) and issue 281 (a machine's condition
read as a module's) are the nearest; neither is this.
### What is measured
- **D1, the report compared with the wrong send.** The gate reads each machine's last report beside the
declaration the machine was *last sent* (`LastReports`: `Current` is "the report names the declaration
in the node's `sent` column"). The release's send wrote `sent`; the control node reported on it, applied;
the merge plan's send at 16:31:37 overwrote `sent`; from then the same report read as not current, and
the gate's wait ended at its bound with "has not reported on what it was sent". The controller restart
at 16:31:44 is in the timeline but is not the cause: the plan and the gate are kept in the store, and a
fresh process read the same `sent` and the same report. Nothing was lost across it.
- **D2, the build judged is not the build running.** `judgeHealth` for the controller asks: the lease is
held since after the send, and the holder says ready. The holder's `Build` is the link-time version
stamp, which the mesh's own builds do not set ("development build"), so it names no build. After the
put-back, the registered controller build, the lease holder and the machine's last send all were the
put-back build, and the newer plan's gate — comparing none of them with the build it sent — passed on
the put-back build's health.
- **D3, a put-back to a controller behind the store.** A failed gate restores the module's registered build
to the one the first machine ran before (`PreviousBuild`) and sends it. Nothing asked whether that
build can read the store as the failed build left it. The controller says so itself when it starts
("this control plane is behind the mesh-controller row"), and serves anyway (ADR 0185).
- **The merge plan's send crossed the release's gate.** `walkedBy` finds a walk in progress by a plan's
module records (`p.Modules[module].First`). A release (the walk of the builds waiting for a gate) keeps its walk in its gate's carried moves,
not in module records, so the release's open gate on the control node held nothing back. And a plan's
own modules were never checked against another walk at all: the check ran over the moves a machine
had waiting, and skipped the plan's own.
### What is guessed
- Why the release's gate had not passed in the nine minutes before the merge is not established. The
node-engine said all three healthy at 16:21:53; the gate needs three passes forty seconds apart after a
two-minute settle, and the controller's own readiness (its self-check run once, `status` within ten
seconds). I think the controller's readiness came late — it was the build being judged — but I did not
read its lease's health at the time.
## The fix
In mesh-controller, pull request 185.
- **A gate keeps what it sent, and reads the report against that.** Each gate keeps, per machine, the
declaration its send carried — its digest and its sequence (`PlanGate.Sent`, read from the node's
record right after the send). A report is on that send when it names that declaration, or one the
machine was sequenced after it. The gate's judging and the merge plan's first-machine wait both read
this; a gate kept before sends were kept reads the last send, as before. Kept with the plan in the
store, so a controller restarted mid-judging reads the same.
- **A gate judges only the build the machine was last sent.** Each gate judges what the machine was
last sent of the module (`sent_builds`) against the commit it judges. Another build there means the
judging is **superseded**: a new verdict, decided at once, with no verdict on the build, nothing marked,
nothing put back; a release so superseded ends, and what still waits is released again; a merge plan
so superseded ends, said, and the newer plan carries on.
- **The controller is never put back to a build older than the store's schema.** A controller is told
which build it is by its declaration (`MESH_CONTROLLER_VERSION`, the bundle's version, `${version}`
composed by the catalogue — now also inside a process's env), and records when it serves the highest
migration it carries, by that version (`controller_schema`, migration 0087). A failed controller gate
reads the record of the build it would put back: one that reaches less than the store has applied, or
never recorded what it reaches, is not put back; the current build is kept, and the condition
(`rollback-failed`, urgent) says why and that a person decides. The lease holder's `Build` is that
version too.
- **A walk of a module on a machine holds every other send of another build of it there.** `walkedBy`
reads a release's open gate as its walk, and a plan's own modules are held by it as any other move is:
a merge plan whose first machine is under a release's gate for the same module waits, said as
"waiting for … which release-… is walking". The same build to the same machine is not held: it is
already there.
Not changed: whether a release's walk and a merge plan's walk of the core should be one walk (the walk
carrying over) is left to a design; serialising them is what this fixes.
## How it is checked
| Rule | Check |
|---|---|
| A newer send to the judged machine does not unreport the gate's | `TestReplay352` (the backlog fixture: the release sends, the machine reports, another send lands unreported, the gate still passes) — fails on the commit before the fix with "has not reported on what it was sent" |
| A send that moves the module supersedes the judging, puts nothing back | `TestReplay352`, `TestAPlansJudgingIsSupersededByASendThatMovesItsModule` |
| A report is held against the gate's own send, by digest or sequence | `TestAReportIsHeldAgainstTheGatesOwnSend`, inventory `TestASendAndAReportAreKnownByTheirDeclaration` |
| Only the build last sent is judged | `TestAGateJudgesOnlyTheBuildTheMachineWasLastSent` |
| A walk holds other sends of the module there, a release's too | `TestAWalkWaitsForAJudgingOfTheSameModuleOnThatMachine`, `TestAPlansFirstSendWaitsForAReleaseJudgingTheModuleThere` |
| No put-back to a controller behind the store, or one unproved | `TestTheControllerIsNotPutBackToABuildOlderThanTheStoresSchema`, inventory `TestASchemaReachIsKeptByBuild`, catalogue `TestAProcessEnvCanNameTheBuildsOwnVersion`, `TestTheHolderNamesTheBuildTheDeclarationToldIt` |
| Every condition of the change is tested | 31 mutations, each failing a test |
A replay in mesh-lab's register is still owed before this core issue resolves (ADR 0237).
@@ -0,0 +1,59 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-controller cmd/mesh-controller (asker.go, asker_wire.go), mesh-controller internal/broker (users.go, asksSeatOf)]
fixed-by: mesh-controller pull request 186
amended-design:
replay-none: the whole of it is one process's record against one bus's user list; the controller's own test drives the asker with the bus's word held and withheld (TestNothingIsAskedWhileTheBusLacksTheControllersGrant), and a container runtime adds nothing to that
---
# 353. The controller asked before the bus held its grant
## Symptom
On 2026-10-09, local times on the control node, from its controller's journal (one process throughout):
- 17:32 the router (the `operator-channel` holder) was unassigned, so that a merge gate of the catalogue had
nothing to send there; 17:52:54 a walk of another module pushed the control node and composed the bus's user
list **without** the controller's grant to ask: the grant is composed from a holder of the seat that is
*assigned*, and none was.
- 17:54:05 the controller started asking (the router was assigned again by then, so its check of the record
said a router was here), and the bus refused its publish of the ask, on the same connection, seven times
until 17:56:05. Each refusal was followed by "the operator could not be asked about machine.…: context
deadline exceeded". The self-check raised `bus.controller.refused`.
- 17:55:56 the push that put the router back on the control node sent it the user list **with** the grant; 17:56:05 the bus reloaded
it in place (the same second the watchdogs saw "jetstream not enabled", the reload's moment); 17:56:10 the
same connection asked successfully, and again at 18:03:53. The server applies a reloaded permission to a
standing connection: no reconnect was needed or made.
- `bus.controller.refused` was raised at 17:54:06 and cleared by itself at 18:11:44, about 15 minutes after
the last refusal at 17:56:05 (the controller's condition history). The condition's evidence lists the same
refusal read again every 30 seconds until 18:10:35; the journal shows no refusal after 17:56:05, so those
are the signal reading its record again, not new refusals.
## Why it is an issue
- **The record said yes before the bus did.** The controller's grant to ask follows the router's assignment
([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)
§3: a caller-named verb is granted to the module that uses the seat), and reaches the bus only when the
machine holding the bus is next pushed. Between `assign` and `push`, and on any push of that machine made
while the router is unassigned, the controller believed its own record of the router and published into a
refusal. The composition is right; the asker trusted it before it was on the bus.
- **The failure was loud in the wrong place.** Seven bus refusals and a warning about the bus, when the fact
was "the control node has not been pushed since the router was assigned", which the controller knows (its
push already judges whether the bus's machine is behind on the user list, to send it first).
## Acceptance test (the operator's view)
While the bus has not been given the controller's grant to ask, the controller asks nothing and the condition
"Questions for you not delivered" says which push carries the grant; once that push lands, the questions go
out, and the bus never refuses the controller's ask.
## How it is checked
- `TestNothingIsAskedWhileTheBusLacksTheControllersGrant` (mesh-controller `cmd/mesh-controller/asker_test.go`):
nothing is asked while the bus's machine was not sent the user list composed now, it is said once, the
conditions that need the operator are raised as undelivered naming the push, and they are asked once the bus
holds the grant.
- Live: after the fix is rolled out, `assign` the router, read `mesh-controller.conditions` for
`asks-undelivered` naming the push, `push` the control node, and read that the asks go out with no
`bus.controller.refused` raised.
@@ -0,0 +1,76 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-host (the node-engine: its repository, module, binary, units, launcher and paths), mesh-tools node-tools (the tool runner: its module, unit and bus account), mesh-controller (the names it keys the core on, and the words it shows), mesh-catalog (the registrations, and the modules that name the two), hq (the designs' code lines, repos.md, the glossary's identifier lines)]
fixed-by:
amended-design:
---
# 354. A decided rename was never scheduled, and the operator believed it live
## Symptom
[ADR 0244](../../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md)
(2026-10-07) settled the words: the program on every node is the **node-engine**, and the program that serves
every module's tools on a node is the **tool runner**. It left the code renames out on purpose — "`mesh-host`
an identifier until the code rename", "`node-tools` an identifier until renamed", and its §6 *What is not
renamed here*: "code is renamed by the repositories that own it, each with its own change" — and named no
issue, no owner and no date for them.
On 2026-10-09 the operator took the rename as long implemented and live. It is not. As of that day:
- **The repositories**, at their heads (the node-engine's at `b0d03be`, the controller's at `e6b00e2e`, the
catalogue's at `6ae112d`): the node-engine's repository and module are `mesh-host`; its binary is
`nox-mesh-host`; its service units are `nox-mesh-host.service`, `nox-mesh-host-resume.service` and
`nox-mesh-host-roused.service`; its launcher is `/usr/lib/nox-mesh-host/launch`; it is delivered under
`/usr/lib/nox-mesh-host/versions/<version>` and keeps its state under `/var/lib/mesh-host`. The tool
runner is the module `node-tools`, the unit `node-tools.service` and the bus account `node-tools`.
- **The live mesh**, read through the controller's `node` verb for the laptop at 18:05: `mesh-host` and
`node-tools` stand among its assigned modules, and `node-tools.runtime process node-tools.service` among
what it runs.
- **The conditions.** An open condition on the control-node (messenger not healthy) says that
"`mesh-host hand-over` at the machine" hands a directory to the mesh (its words, quoted; the operator's line is
`nox node hand-over`, issue 356); every hand-act record of the day
names its caller as `<the laptop>.node-tools`.
- **The record.** `records_search` for "until the code rename" finds ADR 0244's own table row and nothing
else; for "rename mesh-host", nothing. No issue, design or record tracks the rename.
## Why it is an issue
- **A deferral nothing tracks reads as done.** ADR 0244 is accepted; the glossary says node-engine; the
governing documents were reworded the same day. Everything a reader meets says the new name, and the one
sentence that says the code is not renamed says "later" with nobody's name on it. Two days later the
operator believed it done. This is the rule of `AGENTS.md` — an unenforced rule is indistinguishable from
a wrong one — in its deferral form: a record that puts work off without an issue is indistinguishable
from one whose work landed.
- **It is not one record's slip.** The check this issue adds, run over every accepted record from the
date ADR 0244 was accepted, found deferrals without an issue in ADR 0241 ("a later change requires them
once both holders serve them"), ADR 0270 ("left for a later change"), ADR 0273 ("not decided here", four
items) and ADR 0244 itself. ADR 0270 and 0273 are dated 2026-10-09, are held to the rule, and have
[issue 355](../355-a-held-delivery-of-a-module-that-runs-on-one-machine-only-is-never-covered/00-report.md)
and [issue 357](../357-what-adr-0273-left-undecided-has-no-owner/00-report.md) now; ADR 0241 is dated
before the rule's start and stands as found.
- **Impact.** The operator plans against a mesh that does not exist: in conversation the mesh says
node-engine, while every file, unit, package and condition on a node says `mesh-host`, so a person at a
machine (`systemctl status nox-mesh-host`, `ls /var/lib/mesh-host`) and a person reading this repository
do not describe the same thing. The glossary's *Identifier until renamed* lines tell an agent the name is
temporary and never say when.
## The fix, in two parts
1. **This rename** is tracked here. The inventory of what carries the old names, the risk of renaming the
paths of a self-updating daemon on live nodes, and a staged plan are in
[`01-diagnosis.md`](01-diagnosis.md). Acceptance: **`mesh-cli node show <node>` and every condition say
node-engine; no file, unit or package on a node is named `mesh-host`; the tool runner likewise.**
2. **The rule**, from 2026-10-09: a decision that defers work names the issue that tracks it, with an
owner, or the deferral is not accepted. Playbooks [02](../../00-META/process/02-graduation.md)
(graduation) and [04](../../00-META/process/04-build-handoff.md) (handoff) say it;
[`cycle.py`](../../00-META/checks/cycle.py) checks it: an accepted record dated from 2026-10-09, or an
older one corrected under the rule, whose text puts work off ("until renamed", "left for later", "not
decided here", "postponed", "future work", "deferred", "a later change") names the issue as tracking it
("tracked by issue N"), in that paragraph or list item, or in a dated progressive insight in the same
section or quoting it. ADR 0244 carries the insight that points here.
This resolves when the acceptance sentence holds on every node. It is a core issue
([ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md)):
it resolves with a replay, or with `replay-none:` saying why none is possible.
@@ -0,0 +1,114 @@
# 354. Diagnosis
## 2026-10-09
**The record.** `records_search` for "until the code rename" finds only ADR 0244's table row; for the
old name with "rename", nothing. Issues 142, 194 and 210 carry the old name in their titles and keep it: an issue keeps
its words (ADR 0244 §5).
### What carries the old names
**This repository**, on `main` at `c613cf0`:
| Where | What | How much |
|---|---|---|
| `03-DESIGN/01-to-be/` | `mesh-host` and `node-tools` in prose and in `code:` lines | 24 documents, 97 lines; 19 designs name `mesh-host` in `code:`, which is the repository's name and stays right until the repository is renamed |
| `03-DESIGN/00-as-is/` | `code:` lines | 2 documents |
| `00-META/` | `repos.md` (the table says `mesh-host` exists, tier 0), playbook 07's list of repositories, the glossary's two *Identifier until renamed* lines | 3 documents |
| `02-DECISIONS/`, `01-RESEARCH/`, `04-ISSUES/` | the old names as written | 38, 9 and 135 documents; kept, because a record keeps its words |
**The code**, in the checkouts at the heads the report names:
- **The node-engine's repository, `mesh-host`.** Its manifest: module `mesh-host`, slug `host`, the
artifact's binary `nox-mesh-host`, the resource `launcher` at `/usr/lib/nox-mesh-host/launch`, the
resource `next` (the delivered archive) at `/usr/lib/nox-mesh-host/versions/<version>`. Its packaging:
`nox-mesh-host.service` (whose start command is the launcher), `nox-mesh-host-resume.service`,
`nox-mesh-host-roused.service`, `nox-mesh-host-launch`, `nox-mesh-host-rollback`,
`nox-mesh-host-network.sh` and an OpenRC script. The launcher's environment (`MESH_HOST_STATE_DIR`,
`MESH_HOST_LIBEXEC`, `MESH_HOST_BIN`, and its limits) and its fallback binary `/usr/bin/nox-mesh-host`.
The state directory `/var/lib/mesh-host`: the node's state, the foundation lock, and the launcher's
files (known-good, rolled-back, trial, the start counter). The commands `cmd/mesh-host` (its flag set is
named `mesh-host`) and `cmd/mesh-bootstrap`. The placement guard's `engineModule` (`mesh-host`) and
`engineTrees` (both directories). The words a report says, which conditions quote:
"`mesh-host hand-over` at the machine". The Go module path, which the controller's builder check vendors. The
report names a socket under `/run/nox-mesh-host`; I did not find one in the checkout at `b0d03be`, so
the diagnosis in that repository confirms it or strikes it.
- **The tool runner**, in the `mesh-tools` repository under `node-tools/`: the module `node-tools`, the
unit `node-tools.service`, the bus account `node-tools`, the mesh MCP server's description of itself
("reached as <node>.node-tools"), and the caller's address every hand-act record keeps,
`<node>.node-tools`.
- **The controller, `mesh-controller`**: 51 lines naming `mesh-host`, 18 naming `node-tools`. The core
modules the merge gate and the checks key on (`coreModules`: module to repository), the module the probes
key on (`hostModule`), the runtime module (`RuntimeModule`, in the catalogue and the broker packages),
the bus account (`BusNodeTools`), the broker's kind (`KindNodeTools`), the lease witness
(`NodeToolsService`, `ComponentNodeTools`, and a component named `mesh-host`), the seat dependencies, the
reconnects, and the put-back ordering of a send, keyed on the strings `mesh-host` and `mesh-controller`.
The words it shows: `"mesh-host": "the node-engine"` and `"node-tools": "the node tools"` — the second
is a retired word.
- **The catalogue, `mesh-catalog`**: 20 files name one or both — the README, the desktop session code of
five modules, the container runtime's tools and tests, the bus's tools, the service manager's client,
the SSH client's manifest, the lab's tools and the forge's tests. And the registrations: a catalogue row
for `mesh-host` (built from its own repository, ADR 0069) and one for `node-tools` (from `mesh-tools`),
and every node's assignment of both.
The owner of each area is the repository named. For the symptom's words, the whole of the rename, and the
check, this repository.
### The risk
**Renaming the paths of a self-updating daemon on live nodes.** The node-engine delivers itself: its
launcher, placed by the module's own declaration under `/usr/lib/nox-mesh-host`, runs the newest delivered
version under that directory's `versions/` on trial, and goes back to the known-good one recorded under
`/var/lib/mesh-host` when the trial fails
([to-be 45 §8](../../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md),
[ADR 0227](../../02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md)
rule 8). Its unit is placed when the machine joins, not by the mesh
([to-be 21](../../03-DESIGN/01-to-be/21-the-installation-in-full.md): the installer does not install the
unit). So a build that writes under new paths while the launcher reads the old ones is a build the launcher
cannot find; and a launcher that reads only new paths cannot go back to a known-good version under the old
ones — it halts the node ("this node needs a person"), which on a server is a person at a keyboard. The
module's name is keyed in the controller's core handling (the put-back ordering, the gate's core modules,
the lease witness), so a renamed module is, to a controller that knows one name, a new module beside an old
one. The tool runner has the same shape at a smaller scale: the bus account and the unit are named in the
controller's user list and in every node's assignment.
Hence: **one release in which both names are accepted, on every side, before any release in which the old
one is dropped**, each release walked one node first — the laptop, where a halted node is the operator at a
keyboard — and then the rest. I think no stage should move before the delivery fixes of issues 351 and 352
are live, because a core walk is exactly what those broke; that is a guess from their symptoms, not a
measurement.
### The staged plan
0. **This repository** (this change): the issue; the glossary's identifier lines point here; the rule and
its check.
1. **The words shown**, safe at any time and in any order: the controller's shown words ("the node tools"
becomes the tool runner), the node-engine's report words (the hand-over as the node-engine's command),
the mesh MCP server's description of itself. No path changes.
2. **Both names accepted, for one release.** The controller treats `mesh-host` and the node-engine's new
module name as one core component (core modules, witness, put-back ordering), and `node-tools` and the
tool runner's as another (runtime module, bus account, kind). The node-engine's launcher and upgrade
package read the old and the new library and state directories, and the node-engine moves its state
once, the first time it runs under the new name. Both unit names run the same launcher. The conditions
and the `node` verb say node-engine. The merge gate composes every node green; walked one node first.
3. **The rename proper.** The new module names registered, assigned beside the old on the first node, the
old unassigned there, the acceptance sentence checked on that node, then the rest. The repository
renamed on the forge, with its build source and its Go module path; the packaging renamed. The tool
runner the same: module, unit, bus account — and the caller's address in hand-act records changes with
it, which whoever reads those records must know.
4. **The old names dropped**, one release later: the controller's acceptance of them, the launcher's old
paths, the glossary's two identifier lines (the old names move to *Not:* lines, and `words.py` then
fails on them in prose), the designs' `code:` lines and `repos.md` following the repository.
**Acceptance:** `mesh-cli node show <node>` and every condition say node-engine; no file, unit or package on a
node is named `mesh-host`; the tool runner likewise.
### Open
- **The new identifiers.** The glossary's words are node-engine and tool runner; the `nox-` prefix on the
binary and the units is the packaging's. Whether the binary is `nox-node-engine`, the directories
`/usr/lib/node-engine` and `/var/lib/node-engine`, and the tool runner's module `tool-runner`, is the
operator's call before stage 2 is built.
- **Whether the state directory moves at all.** What is under it is the node's own (the report sequence,
the known-good version): a new name beside a kept path is an option the diagnosis in the node-engine's
repository weighs.
@@ -0,0 +1,40 @@
---
status: open
opened: 2026-10-09
located-in: []
fixed-by:
amended-design:
---
# 355. A held delivery of a module that runs on one machine only is never covered
## Symptom
[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)
(2026-10-09) ends a held delivery by itself when a later merge that contains it was delivered to every
machine running its modules — the delivery is *covered*. Its consequences say: "A module that runs on one
machine only is never covered. A walk records 'the rest running it' only after its first machines passed
and the rest were sent. A module on one machine has no rest, so its steps never say it reached every
machine. mesh-delivery itself, on the control node alone, is such a module: a held merge of it stays held
for a person, as before this record. Reading 'every machine running it' from the first machines when they
are all the machines would close it, and is left for a later change."
That later change had no issue, no owner and no date. The check added with
[issue 354](../354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)
found the sentence on the day the record was accepted. This issue is the pointer the record lacked; the
record carries a dated insight naming it.
## Why it is an issue
A held delivery of mesh-delivery — or of any module placed on one machine — that a later merge covered
stays held for a person, which is the hand-work ADR 0270 set out to remove; and nothing told the operator
this case was excluded except one sentence in the record's consequences. A deferral nothing tracks reads as
done (issue 354).
## Open
- Whether a walk whose first machines are all the machines should record that it reached every machine
(the record's own suggestion), or whether *covered* should read the machines running a module from the
placement rather than from the walk's steps.
- The owner is mesh-delivery, in the controller's repository, as the record says; `located-in:` is filled
when the diagnosis confirms where the walk's record is written.
@@ -0,0 +1,119 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-host (cmd/mesh-host, internal/upgrade, internal/link), mesh-controller (cmd/mesh-controller/nodes.go, internal/broker, internal/link), mesh-catalog (checks/words)]
fixed-by:
amended-design:
---
# 356. The operator's command on a node ran a stale engine, and a hand-over had no channel
**Severity: high.** A command on every node answered for a build that was not running, and the one act a
condition asked of the operator could not be done through the mesh.
## Symptom
On 2026-10-09 at 18:01 (local) the operator, on the control-node, did what a module's condition told them to:
the messenger module was waiting for a person, its data directory used as found
([issue 339](../339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md)), and the condition said to
run the node-engine's `hand-over` "at the machine", as root. They typed it, with the directory's path, and the
node-engine answered **"unexpected argument"**.
Measured on that machine, right after:
- The command on `PATH` is the engine placed by hand at installation (`/usr/bin/nox-mesh-host`, the launcher's
fallback). Its `help` lists `profile`, `inventory`, `apply`, `reconcile`, `bundle`, `overlay`, `owned` and
`version`, and no `hand-over`: it is a build from before that subcommand existed.
- The engine running the machine is a delivered one, under the engine's own versions directory, started by its
launcher ([ADR 0141](../../02-DECISIONS/0141-the-host-delivers-its-own-successor.md)). It has `hand-over`.
Nothing had ever written the delivered build over the one placed by hand: the launcher runs the fallback only
while nothing is delivered, and the engine's retirement of old versions never touches it.
- Meanwhile the messenger's process crash-looped: its data directory was owned by an account of the machine
(uid 1000, mode 700) and left so, as issue 339 decides, while the module now runs as an account of its own.
So two faults, one visible through the other:
1. **The command the operator types on a node is not the engine that runs it.** After every delivery of the
engine, the file on `PATH` falls one build further behind, and answers — with `help`, `version`, `owned`,
or a refusal — for a build the machine is not running.
2. **The hand-over of issue 339 was a terminal act at the machine with no channel.** The condition told the
operator to go to the machine, as root, and type the daemon's name. [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)
had given the operator one command, `nox`, on every node, and the operator's rule of the same day says a
person never types a daemon's name; the way out of issue 339 was written before either.
## Why it is an issue
- **A tool that answers for a build that is not running is the lie this mesh exists to refuse.** The mesh's own
words: evidence over assertion, failure must be loud. The file on `PATH` asserted a version, listed commands,
and refused one that exists, with a successful-looking refusal.
- **An act the mesh asks of its operator must have a channel the mesh provides.** ADR 0272 made "at the
controller's terminal" a real place. "At the machine, as root" is the opposite: `ssh` and `sudo`, which the
operator's rules forbid for a mesh machine, and which no agent may do either.
- **The texts the mesh addresses to its operator named the daemons' lines** — the condition, the engine's
statement, the apply's log, READMEs and design documents — and nothing checked them.
## Impact
- On every node, from its first delivered engine on, `nox-mesh-host …` typed at the machine ran a stale build.
On the four nodes today that is a build from before `hand-over`, the tunnel take's `--tunnel`, and the
judgements of ADR 0266.
- The messenger module stayed down for as long as its directory stayed as found, and the only way out the mesh
named could not be typed.
## The acceptance test (the operator's view)
- After a delivery of the node-engine proves itself on a node, `nox-mesh-host version` typed at that machine
answers the build the launcher is running, and its `help` lists what that build serves.
- `nox node hand-over <node> <path>` on the control-node, from the operator's own account, hands the directory
the module's condition names to the mesh; the engine on that node says so in the answer; the condition clears
at the node's next apply, which gives the directory its declared owner and mode. The same line through any
verb, or from another node's `nox`, is refused and nothing is recorded.
- No condition, verb answer, README or design document tells the operator to type the node-engine's or the
controller's binary; `merge-check.sh` here and in the catalogue fail on one that does.
## How it is checked
- **The engine placed by hand becomes the proved build** (`mesh-host` `internal/upgrade`, `cmd/mesh-host`): on the
evidence known-good is written on — the mesh has taken a report under this build — the engine copies itself
over the fallback when the bytes differ; a copy, renamed into place, never a link. Tests: a stale fallback
becomes the proved engine's bytes with mode 0755 and the running file is untouched; one already a copy is
left as it is (same inode, no temporary file left); an engine running from the fallback itself renews nothing;
a missing fallback is placed; a link at the fallback's path is refused and its target untouched; an
unreadable running engine is said, not copied as nothing; the fallback's path is the launcher's variable or
its default. Each fails with its check taken out.
- **The hand-over is the controller's terminal's, carried to the engine** (mesh-controller
`cmd/mesh-controller/nodes.go`, `internal/link`, `internal/broker`; `mesh-host` `internal/link`,
`cmd/mesh-host`): `node hand-over <node> <path>` is a `node` subcommand that is not a read, so the controller's
terminal-only rule refuses it through every verb and through `mesh-cli` outside the terminal — tested
through `terminalOnly`, the generic command verb's composition and the ordinary mesh-cli line. The line's
node and path are judged before anything is asked (absolute, clean, no `..`, no doubled or trailing
separator; a node the mesh knows). The ask is a request on the machine's own subject. **The subject
proves nothing about who asked**: only the controller is granted a publish there (a row of the writers table,
held at composition), but the bus lets any principal allowed to answer reply to a message it received on the
reply subject that message named, so a tool server running as the operator's account could deliver a body there
(found by the review of 2026-10-09). So the controller **signs the ask** with the mesh's key — the node, the
path, who asked, an expiry a minute ahead and a fresh nonce, over a fixed context so it is never a declaration's
signature — and the engine verifies it with the key it verifies declarations with before it reads anything out
of it, then refuses an ask for another node, expired, good for too long, without a nonce of its own, or taken
before. Tests on the engine's side refuse each of those (unsigned, another key, signed as a declaration, the
bare ask, another node, expired, no expiry, too long, no or a short nonce, a replay) with the handler given
nothing; the controller's side holds the signature, the context, the nonce and the expiry. The node's grant gains
the subject and the right to answer what it was asked (the golden composition shows exactly that). The engine
then judges every value before it records anything — who asked (one short line), the path (absolute, clean,
one its state records as used as found, on disk a directory and not a link, reached through no link) — and a
refusal records nothing. **At the apply, the link is refused again at the moment of use**: a handed-over
directory must still resolve to itself, and is changed only through one descriptor opened from `/` with no
link followed on any component; a test swaps its parent, its grandparent and itself for links to a victim
tree after the hand-over, and the victim keeps its mode. The request's and the answer's field names are held by a test on each
side.
- **The words**: the engine's statement, the controller's wait and condition name
`nox node hand-over <node> <directory>` on the control-node, and never the engine's binary; the condition's
words are plain (a path is in the evidence, never in the summary). hq's `words.py` and the catalogue's
`checks/words` refuse a daemon's line (ADR 0272, its insight of 2026-10-09); each has a test of its pattern,
and `words.py` compares the catalogue's copy.
- **Live, at acceptance:** after the controller and then the engine are delivered, the four nodes' journals say
"renewed the engine placed by hand … to this build", and `nox-mesh-host version` at a machine answers the
delivered build; `nox node hand-over <control-node> <the messenger's data directory>` answers that it is handed
to the mesh, the messenger's condition clears at the next apply, and the module runs.
The diagnosis is in [01-diagnosis.md](01-diagnosis.md).
@@ -0,0 +1,93 @@
# Diagnosis — issue 356
## 2026-10-09, 18:01 — what was measured
On the control-node, the operator typed the node-engine's `hand-over` with the messenger's data directory and was
answered "unexpected argument". `help` from the same command listed no `hand-over`. The launcher's journal named
the engine it runs: a delivered build under the engine's versions directory. The messenger's unit crash-looped;
its data directory was used as found, owned by a machine account (uid 1000) with mode 700, while the module had
moved to an account of its own.
## The causes, from the code
**1. Nothing ever wrote a delivered engine over the one placed by hand.** The launcher (`mesh-host`
`packaging/nox-mesh-host-launch`, delivered as the engine's module file) picks the newest delivered version that
was not rolled back, and falls back to `/usr/bin/nox-mesh-host` — the file a person copied at installation
([ADR 0141](../../02-DECISIONS/0141-the-host-delivers-its-own-successor.md)) — only while nothing is delivered.
The engine's own upgrade code (`internal/upgrade`) delivers under the versions directory, proves a build by
writing `known-good`, and retires versions older than the running one's predecessor; it never reads or writes the
fallback. The fallback is also the one `nox-mesh-host` on any `PATH`, so from the first delivery on, what a person
typed was a build older than what ran. Nothing said so: the engine's `version` answers from its own path, and the
stale file answers its own.
**2. The hand-over was a local subcommand only.** Issue 339's way out, `hand-over <dir>` (`mesh-host`
`cmd/mesh-host/found.go`), reads the node's state and writes `handed-over.json` beside it; the next apply reads
that file. It had no bus side: the engine hears the mesh on its declaration subject and on
`mesh.node.<node>.ask.report` only, and the controller had no `node hand-over`. The engine's statement, the
controller's wait text (`module_health.go`) and the condition's "needs" line all told the operator to run it "at
the machine", as root.
**3. No check held the texts to ADR 0272.** `words.py` reads the glossary's retired words; a daemon's command
line is not a word.
## Ruled out
- A delivery that failed half way (the versions directory holding an older build): the launcher's journal named
the delivered build, and `known-good` named it.
- The launcher running the fallback: it does so only while nothing is delivered, and three versions were.
## The fix
**Which fix for the stale command.** Two were weighed. (a) `/usr/bin/nox-mesh-host` becomes a small program that
runs the launcher's current version. Rejected: the launcher runs that very file as the engine while nothing is
delivered, so a shim there would run itself; and the choice of version (newest delivered, never one rolled back,
a rollback's pin) is the launcher's policy, which a shim would copy. (b) The proved engine writes itself over the
file when the bytes differ: a copy renamed into place, never a link, on the same evidence `known-good` is written
on. Chosen. The launcher's fallback is then the newest proved build, which is also the best one to fall back on,
and the command on the machine is the engine that runs it.
**The hand-over's channel.** `nox node hand-over <node> <path>` on the control-node. The controller's `node`
subcommands that are not reads are terminal-only already (ADR 0266's allow list), so the line is refused through
every verb and through `mesh-cli` from any other node without a new rule. The controller judges the line, asks
the node's engine on `mesh.node.<node>.ask.hand-over` — a request on core NATS — and prints the engine's answer,
failing on a refusal. Only the controller is granted a publish on that subject (a writers-table row), but that
does not say who the engine hears: the bus lets a principal allowed to answer reply to a message it received on
any reply subject the message named, and the tool runner, which runs as the operator's account and so as every
agent, is allowed to answer (the review of 2026-10-09 proved it on a permissioned server). So the ask is signed
with the mesh's key (node, path, who asked, a minute's expiry, a nonce; over a fixed context, so the signature is
never a declaration's), and the engine verifies it before reading anything, refusing another node's, an expired
one and a replayed nonce. The engine judges every value and records the hand-over, or refuses and records
nothing. **The apply checks again**: a component of the path may have become a link after the hand-over was
recorded, so a handed-over directory must still resolve to itself and is changed only through a descriptor opened
from `/` that followed no link. The engine's local `hand-over` stays, for a
machine no mesh can reach; its usage says the operator's line.
**The words.** Every text addressed to the operator names the `nox` line. hq's `words.py` gains the daemons'
lines (ADR 0272's insight of 2026-10-09 says the rule and the check); the catalogue's `checks/words` refuses the
same lines in module strings and manifest descriptions, from its copy `retired-lines`.
The pull requests: `mesh-host` #74 (the fallback renewed, the engine's side of the ask, its words),
mesh-controller #187 (the `node hand-over` line, the node's grant, its words),
mesh-catalog #165 (the catalogue's check and its copy of the lines), and this one (the record, `words.py`, the
documents' lines).
**Order of delivery.** The controller first: the node's bus grant (the subject, and the right to answer) is in
the controller's composition of the user list, and an engine delivered before it would have its subscription
refused and say so. Then the engine. The catalogue's check and this record in any order.
## What remains
- **A handed-over directory goes through no link on every apply, not only the first** (the confirmation review
of 2026-10-09). After the first apply the record is no longer used as found, so the directory read as the
mesh's own and was given its mode and owner by path. The hand-over is now checked before that, and a test
swaps a parent after an apply already gave the directory its owner (`mesh-host` #74).
- **The same by-path change for every other mesh directory outside a home is a gap older than this issue**, and
is left to [issue 358](../358-a-mesh-directory-outside-a-home-is-changed-by-path-through-any-link/00-report.md):
closing it here would change every placement on every node.
- A mesh-lab replay of the stale command (an engine delivered twice, the fallback compared) and of the hand-over
(asked, refused through a verb, recorded through the terminal) is owed before this issue resolves: a core
issue resolves with `replay:` ([ADR 0237](../../02-DECISIONS/0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md)).
- The engine's other local subcommands (`key`, `enrol`, `overlay take`, `reconcile`, `apply`, `profile`,
`inventory`, `owned`, `bundle`, `version`) stay install-time and rescue commands, typed as root at a machine no
mesh reaches; the engine's usage and README say so. A `nox` line for any of them is a later decision.
@@ -0,0 +1,41 @@
---
status: open
opened: 2026-10-09
located-in: []
fixed-by:
amended-design:
---
# 357. What ADR 0273 left undecided has no owner
## Symptom
[ADR 0273](../../02-DECISIONS/0273-the-agents-home-is-the-modules-and-the-operators-personal-rules-and-lessons-are-registered-through-it.md)
(2026-10-09: the agent's home is the module's, and the operator's rules and lessons are registered through it)
says in its consequences that four things are not decided there, and names no issue, owner or date for any of
them. The check added with
[issue 354](../354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md) found
them on the day the record was accepted. This issue is the pointer the record lacked; the record carries a
dated insight naming it.
1. **Clearing old copies of hand versions.** The agent module keeps the hand version of every file it undoes,
in its state, and "clearing old copies is not decided here, as ADR 0243 left it". Without it the module's
state only grows.
2. **A per-project lesson** — one bound to a repository rather than the operator.
3. **Lessons for an agent the mesh runs**, rather than the operator's own agent.
4. **A channel of the operator's own that can tell the operator from an agent** — what issue 340 calls
"once we build a UI" — which would become a third route for registering into the operator's home.
## Why it is an issue
A deferral nothing tracks reads as done (issue 354). Each of the four is a gap a reader of ADR 0273 can only
find by reading its consequences to the end; the first is also a store that grows without a bound.
## Open
- Whether each item needs a decision record of its own, or whether item 1 is a setting of the agent module
that its owner (the `claude-code` module in the catalogue) can decide in a change.
- Item 4 waits on the operator's channels (ADR 0259) and the controller's terminal; its owner is not known
until the channel is designed.
- `located-in:` is filled when each item's owner is confirmed. The item resolved first is struck through here
with its pull request, and the issue resolves when all four are decided or declined.
@@ -0,0 +1,47 @@
---
status: open
opened: 2026-10-09
located-in: [mesh-host (internal/apply/apply.go applyDirectory, internal/apply/home_links.go chmodDirPath, chownPath)]
fixed-by:
amended-design:
---
# 358. A mesh directory outside a home is given its mode and owner by path, through any link in its path
## Symptom
Found by the review of [issue 356](../356-the-operators-command-ran-a-stale-engine-and-a-hand-over-had-no-channel/00-report.md)
on 2026-10-09, by reading the code; nothing was run against a node.
The node-engine sets a directory's mode and owner by path when the directory is the mesh's own and lies outside
an account's home: `applyDirectory` calls `chmodDirPath` and `own`, which call `os.Chmod` and `os.Chown` on the
path. Both follow a link anywhere in the path. The directory applier refuses a link at the directory's own name,
and issue 339's placement guard refuses the machine's own trees, but nothing refuses a link in a parent at the
moment of use. Below a home the engine already walks every component without following a link (ADR 0266).
Issue 356 closed this for a directory handed to the mesh by the operator: it is changed only through one
descriptor opened from `/` with no link followed, on every apply. The same treatment for every other mesh
directory was left out of that fix on purpose: it touches every placement on every node, and a machine whose
data lives behind a parent that is a link (a mount point reached through one, say) would be refused where it
is applied today.
## Why it is an issue
Root follows a path a parent of which another account may be able to replace. Whoever can turn a parent of a
mesh directory into a link between two applies directs root's `chmod` and `chown` at the link's target.
## Impact
Not known whether any node has a mesh directory with a parent another account can write; finding that out is
the first step of the diagnosis.
## The acceptance test (the operator's view)
No apply of any node changes the mode or owner of anything but the directory its declaration names, whatever
link appears in that directory's path between applies; and every directory the mesh applies on the four nodes
today still applies.
## How it is checked
To be written with the fix: a test that swaps a parent of an ordinary mesh directory for a link after an apply
and finds the link's target untouched; and the guard run over the declarations every node would be sent today.
@@ -0,0 +1,44 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-controller (cmd/mesh-controller/desk_secret.go, internal/conditions/plain.go)]
fixed-by:
amended-design:
---
# 359. A secret given reached the operator as "needs a look", and nothing said why
**Severity: medium.** The one notification meant to tell the operator that a module's own secret changed told
them nothing they could act on, and the fall back to borrowed words was silent everywhere.
## Symptom
On 2026-10-09 at 23:32 (local) the operator gave a chat module's bot token at the controller's terminal, with
the controller's `secret accept` and the value on standard input. The urgent desktop notification that followed
read, in full:
> Novox needs a look — Needs you: read the details to see what to do. The mesh noticed a problem it calls
> "secret given" with novox.
That is the scope's fallback wording ([ADR 0253](../../02-DECISIONS/0253-a-condition-says-itself-to-the-operator-in-plain-words-beside-its-summary.md)),
with the verdict kept as [ADR 0258](../../02-DECISIONS/0258-a-desk-click-performs-only-an-acknowledgement-and-an-answer-is-a-one-time-ticket.md)
requires. It names neither the module nor what was given, and "read the details" sends the operator elsewhere
for the one thing the condition exists to say: a secret changed, and silencing it is the answer when they gave
it themselves. The condition was raised on every channel on purpose — the security review of 2026-10-09 wanted
a bot token changed by somebody else to be heard of — so the words are the whole of its value.
## Two faults
1. **The condition's own words broke the plain rule.** Its producer wrote a headline, an explanation, a needs
line and a resolved line of its own; the explanation carried the clock time of the giving. The plain rule
refuses a clock time (the channel says when, in the operator's time), so the keeper replaced all of them with
the scope's words.
2. **The replacement was silent.** The keeper tells a hook of every observation said in borrowed words. A test
suite sets that hook and fails on it; the serving controller sets none, so nothing — no log line, no
condition — said that a producer's words had been refused, or why. The condition's own unit test built the
observation without passing it through a keeper, so the suite's check never saw it either.
## What is asked
- The secret-given words pass the plain rule, with a test that holds them to it through a keeper.
- Words refused at run time are never silent: the keeper logs the kind and the reason, once per process.
@@ -0,0 +1,36 @@
# Diagnosis — issue 359
## 2026-10-09 — the cause, confirmed by a test
A test in the controller's repository built the secret-given observation as the terminal does (the module, the
secret's name, "at the controller's terminal", 23:32) and held its words to the plain rule. The answer was
`explanation: a clock time or date (23:32)`. With the time removed and nothing else changed, the same words
passed, from the terminal and from a desk. The secret's name, written with a hyphen, is not refused by the rule;
the clock time alone was the cause.
The keeper's fallback (`plainly` in the controller's conditions package) then took the scope's words: "… needs a
look", and "a problem it calls" the kind with its hyphens as spaces. Since the producer had a needs line, the
verdict stayed "Needs you:" with the fallback needs, "read the details to see what to do."
## Why nothing said so
The hook told of borrowed words (`Unworded`) is set only by test suites. In the serving controller it is unset,
and the keeper replaced the words without a trace. The suite-wide check in the controller's command package
fails any condition a test raises through a keeper with words that are not plain; the secret-given test called
the producer directly and asserted on its fields, so that check never ran on it.
## The fix
In the controller's repository, on its own pull request, linked to this issue:
- The words name the module and the machine, say the secret's name as words ("telegram token"), say how it was
given, and carry no time: "New secret given for telegram on" the machine; "If you gave it, nothing else is
needed. If you did not, somebody else now holds what telegram acts with." The needs line says silencing is the
answer when the operator just gave it. Severity (urgent) and the one answer (silence for a week) are unchanged.
- A test holds the words to the plain rule and observes them through a keeper, so the suite's check covers them;
it fails against the old words with the cause above.
- With no hook set, the keeper logs which kind is said in borrowed words and why, once per kind and reason per
process, so a refusal in the running controller is visible in its journal.
Ruled out: the bound on lengths (the old needs line was 102 characters of 120), the outward content rule (no
address or secret shape in the words), and the machine name.
@@ -0,0 +1,36 @@
---
status: located
opened: 2026-10-10
located-in: [mesh-catalog modules/dunst (the dunstrc's click lines and its context menu)]
fixed-by:
amended-design:
---
# 360. The operator cannot choose a notification's action on a touchpad
**Severity: medium.** The desk's answers ([ADR 0253](../../02-DECISIONS/0253-a-condition-says-itself-to-the-operator-in-plain-words-beside-its-summary.md),
[ADR 0258](../../02-DECISIONS/0258-a-desk-click-performs-only-an-acknowledgement-and-an-answer-is-a-one-time-ticket.md))
reach the screen and cannot be given there. Every notification with actions ends unanswered, or is answered
on another channel.
## Symptom
The operator works on the laptop and on the workstation with a touchpad, which gives a left and a right
click but no middle click. A desktop notification from the desk that carries actions (for example "Silence
for a week") shows dunst's mark for them, "(A)", and nothing the operator can do on it chooses one:
- a tap (left click) closes the notification, because the dunstrc the mesh writes says
`mouse_left_click = close_current`;
- the actions are bound to the middle click alone (`mouse_middle_click = do_action, close_current`), which
the touchpad cannot give;
- the context menu, the other way to an action, does not open either. The dunstrc names `dmenu`, the
node-launcher's command, which lives in the account's own `~/.local/bin`. dunst runs in the account's
service manager, and on the laptop on 2026-10-10 the running dunst's `PATH` held no such directory.
ADR 0253 states the desk's actions are offered "a middle click, or its context menu". On these two
machines neither is reachable, and nothing says so: the notification closes on a tap as if it had been read.
## Expected
On the laptop and the workstation: a tap on a notification with one action runs it; with several, a menu
(rofi) opens to choose one; a notification without actions closes on a tap, as today.
@@ -0,0 +1,50 @@
# Diagnosis — issue 360
## 2026-10-10 — what dunst does on each click, from its own documentation and code
dunst(5), for the version the machines run (1.13.2): `mouse_[left/middle/right]_click` takes a list of
`none`, `do_action`, `open_url`, `close_current`, `close_all`, `context` and `context_all`, run in order.
`do_action` "invokes the action determined by the action_name rule. If there is no such action, open the
context menu", and `action_name` defaults to `default`: "the action set as default action or the only
available action will be invoked."
dunst's source (`notification_do_action` in `src/notification.c`, the click handler in `src/input.c` and the
menu in `src/menu.c`, tag v1.13.2) settles the three cases:
| the notification has | `do_action` | then `close_current` |
|---|---|---|
| one action | runs it at once | closes it |
| several actions, none named `default` | opens the context menu for this notification and locks it | the close waits for the menu; it happens after the chosen action is sent, and not at all when the menu is dismissed |
| no action and no link | nothing | closes it |
| no action, one link | opens the link in the browser | closes it |
| several actions, one named `default` | runs `default` without a menu | closes it |
A touch on Wayland is also read as the left click (`BTN_TOUCH`).
So `mouse_left_click = do_action, close_current` gives the expected behaviour, with two edges: a link in a
notification without actions opens on a tap, and an action named `default` would be taken without the menu.
The desk's tokens are tickets, never `default`; the dunst module's `send` can refuse that token so the edge
cannot be reached.
## The context menu
The dunstrc's `dmenu = dmenu -p dunst` calls the node-launcher's dmenu-compatible command, a script the
launcher module places in the account's `~/.local/bin`. The `PATH` of the running dunst on the laptop was
read from its process environment on 2026-10-10 and held only system directories and a tool's own, not
`~/.local/bin`. dunst starts the menu with a search of that `PATH` (`g_spawn_async_with_pipes` with
`G_SPAWN_SEARCH_PATH`), and a command it cannot find is only a line in its log ("Cannot spawn dmenu"), so
the menu does not open and the operator sees nothing. Calling
`rofi -dmenu` (a system package) removes the dependency on the account's `PATH`; the dunst module then
declares the `rofi` package itself, since its file names it.
## Ruled out
- The keyboard: i3's configuration is the mesh's (the `i3` module, with other modules' lines placed from
their contributions to `node-display-session`, ADR 0212), so a key for `dunstctl context` can be one more
contribution, from the dunst module. It does not replace the tap, which is the operator's way on a
touchpad.
## Owner
mesh-catalog, `modules/dunst`: its dunstrc (the left click and the menu), its packages, its contribution to
i3, and the token check in its `send`.
@@ -0,0 +1,61 @@
---
status: located
opened: 2026-10-10
located-in: [mesh-host (internal/accounts, rootsearch), mesh-controller (cmd/mesh-controller/agent_account.go, internal/link)]
fixed-by:
amended-design:
---
# 361. The control-node's agent account is never judged: its search for setuid programs cannot finish in fifteen minutes
**Severity: high.** The router refuses to link a messaging channel on a machine that is not root-free
([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),
and a machine whose agent account is not judged is not root-free. On the control-node the judgement can never
be given, so linking the operator's phone there is blocked for as long as the search is bounded.
## Symptom
On 2026-10-10 at 00:43 (local), after the control-node was given an agent account
([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)),
the controller's `root-free` verb for that machine answered:
> not judged (search incomplete): the search for setuid programs did not finish within 15m0s; it is tried
> again later
The control-node holds its root and home filesystems on a software RAID of spinning disks, with millions of
files between them. The node-engine's search for setuid programs (ADR 0266 §3 item 10) walks every local
filesystem mounted without `nosuid`; that walk, cold, takes far longer than fifteen minutes there. Each attempt
was killed at its bound, judged nothing, and was tried again after a back-off, which ends the same way.
## Why it is a design fault, not one slow disk
- **A bounded search judges nothing on a machine larger than the bound.** The bound was chosen so the
controller could tell "still running" from "stuck" (`rootsearch.Bound`, read by both). It turned a slow
machine into one that is never judged, failing closed for good rather than for a while.
- **Nothing was kept across a restart.** The search's result lived in the engine's memory only, so every
engine restart (every update of the engine) started from nothing and judged nothing until a whole walk
finished again.
- **The controller's quiet was the same value.** The self-check stayed quiet while a search was "within its
bound" — fifteen minutes — so a search that runs for hours would raise the urgent condition on every
restart even once the search itself was allowed to finish.
## What is asked (the operator's direction, 2026-10-10)
1. The search runs to completion, in the background, at the lowest CPU and I/O priority, one at a time, with
no bound on the whole search.
2. It is resumable: its progress is kept every minute beside the engine's state, so a restart, an update or a
reboot carries it on from where it was rather than starting over.
3. Its outcome is kept, with when it started and finished. A verdict is given only from the last **complete**
search while it is younger than a stated age; "not judged yet" before the first one completes; never
"cannot become root" from an incomplete or stale one.
4. A fresh search runs at most once a day, and after any apply that changes an account, a group, a sudo rule or
a package.
5. The controller's own reading of the search (its quiet) changes with it, consistently.
**Why a day-old search is sound.** Only root can make a setuid- or setgid-root file: the agent account can
neither set the bit on a file root owns nor give a file of its own to root. So a complete search stays true until
root acts, and root's acts through the mesh (applies) start a new search. A file that appears behind a running
walk's cursor was made by root too. What is left is root acting by hand — the operator — which the stated age
bounds.
The trail and the fix are in [`01-diagnosis.md`](01-diagnosis.md).
@@ -0,0 +1,149 @@
# 361 — diagnosis
## 2026-10-10 — where the bound lives, and who read it
- **The node-engine** (`mesh-host`, `internal/accounts`): `SetuidCache` ran the search in the background, at most
hourly, under a context with a deadline of `rootsearch.Bound` (15 minutes) for the whole search — `find` over
every mount point with `-xdev`, then the package manager about every path found. A search past the deadline
was an error, said as `not judged (search incomplete)`, and tried again after a back-off doubling from an hour
to six. The result was kept in memory only.
- **The controller** (`mesh-controller`, `cmd/mesh-controller/agent_account.go`): `searchQuietFor` was
`link.RootSearchBound`, which is `rootsearch.Bound`. It decides only how long the self-check raises nothing
while an agent account's verdict says `not judged yet (search running)`, counted from when the controller
first saw it waiting. The `root-free` verb and `judgedConfined` never read it: they need a healthy verdict in
a statement heard within 15 minutes (`verdictFreshFor`, a different value — how often an engine states its
health — which this issue does not change).
- **Nothing else** used the bound: `SetuidCache` had one caller (`moreWays`), and the engine made one cache.
- **Ruled out:** a stuck `find` (each attempt ended at exactly its bound) and a filesystem that should not be
searched (the walk covered only the local root, home and boot filesystems, as ADR 0266 asks).
## The fix (`mesh-host` pull request 75, `mesh-controller` pull request 190)
**The walk is the engine's own and resumable.** `filepath.WalkDir` per filesystem, staying on its device as
`find -xdev` did, with the same prunes and the same match (a regular file, setuid and owned by root, or setgid
and of root's group), never following a link. It visits each directory's entries in lexical order, so its
progress is one cursor per filesystem — the last path visited — compared name by name (`/a/b` is walked before
`/a-c` although `-` sorts before `/`). Every minute the progress (when the walk started, its filesystems, those
done, the cursor, the matches so far) is written beside the engine's state as a new file renamed into place,
readable by root alone. After a restart the walk carries on from the cursor: what is before it is skipped
without being read, the directories leading back to it are read again. A walk older than the stated age, or one
an apply asked to redo, is thrown away and begun again.
**Low priority, one at a time, no bound.** The walk runs on a goroutine locked to an operating-system thread of
its own, set to nice 19 and the idle I/O class; on Linux both are a thread's, so the engine's other work keeps
its priority, and the thread ends with the goroutine. The package manager is run the same way. A priority that
cannot be lowered fails the search rather than walk at full priority. There is no deadline: the search ends when
it has walked everything, or with an error (a directory it could not read), which is incomplete.
**A verdict only from a complete, fresh search.** The last complete search is kept beside the engine's state
with its start and finish, and judges while it is younger than `rootsearch.FreshFor`, counted from its start.
Before one completes, or once it is older, the verdict is `not judged yet (search running)`; after a failure,
`not judged (search incomplete)`. A kept file is believed only as a regular file, opened without following a
link, owned by the engine's account and writable by it alone, in a directory that is the same.
**When a search runs.** Once a day (`rootsearch.Every`, from the last complete one's start); after an apply
whose outcomes moved a user, a package, an action, or a file of the user and group databases or of sudo's,
doas's or polkit's rules (`apply.MovesRoot`), at most once an hour from the last search's end, the last complete
search judging meanwhile; and at once when a filesystem appears that the last search did not walk, which that
search does not judge.
**The values, one place** (`rootsearch`, read by both repositories):
| Value | What it is | Set to |
|---|---|---|
| `Every` | how often a full search runs at most | 24 hours |
| `Quiet` | how long the controller raises nothing while an agent account waits for a search, from when it first saw it waiting | 12 hours |
| `FreshFor` | how long a complete search judges, from its start | `Every` + `Quiet` = 36 hours |
`FreshFor` is `Every` plus `Quiet` so that a daily search which finishes within the controller's quiet never
leaves an hour unjudged; the controller's test holds that relation. The operator's direction named 26 hours as an
example; 36 is chosen because a full walk at idle priority on the control-node is expected to take hours, and
with 26 hours a daily search longer than two hours would leave the account unjudged — and phone linking refused —
every day until it finished.
## 2026-10-10 — the independent review, and what it changed
An independent review of both pull requests asked four questions: can a stale or incomplete search yield "free";
can the agent account steer the kept files or the walk; does the walk hurt the machine; do the controller and the
engine agree. What it found, and what was done:
- **A cursor of a name that is not UTF-8 came back changed.** JSON strings replace such bytes, so a resumed walk
skipped every name that sorted between the real cursor and its replacement, and could finish "free" without
having read them. Any account can make such a name. The walk's paths are now kept as their bytes.
- **An ask to search again was lost to a restart**, and the restarted engine carried on the walk that started
before root acted. The ask now forgets the walk under way and stops it keeping progress.
- **A fresh search judged a filesystem it never walked.** A complete search now judges only while it walked every
filesystem a search would walk now; a filesystem mounted since makes the verdict "not judged yet" until walked.
- **A mount point inside a walked filesystem was read before it was known to be another filesystem.** A FUSE
mount root cannot read failed the walk, and a network mount whose server is away could hang it. Every mount
point the kernel lists is now left unread. Mount points are read with all the kernel's escapes.
- **A file found and gone before the package manager was asked failed every resumed search.** The walk looks at
what it found again when it ends, and drops what went or is no longer setuid-root.
- **Outcomes that moved nothing started a search**, and a search asked for could follow a walk of hours at once.
Only created, updated, corrected and removed outcomes count now, and an ask waits an hour from the last
search's end.
- **Not changed, because the mesh cannot do it:** the review asked that a file or archive declared setuid start a
search. The engine writes a file's and an archive's permission bits only, so neither carries a setuid bit.
## Open, after the fix
- **How long the first walk on the control-node takes is not measured.** Until the first complete search, the
verdict there is `not judged yet`, and linking stays refused. The engine says each search's end and how long
it ran in its journal; that line is the measurement. If a walk takes longer than `Quiet`, the self-check
raises the urgent condition, which is right: it is then not known to finish.
- **The idle I/O class is honoured only by a disk scheduler that has classes** (BFQ). Under another scheduler
the walk still runs at nice 19 but competes for the disk as any reader does.
- **An ask to search again is not kept across a restart.** The walk under way is forgotten when it is asked,
so a restarted engine does not carry an older walk on; the ask itself is lost, and the daily search covers it.
The last complete search keeps judging meanwhile, on the reasoning in the report.
- **The agent account can make the search fail, never pass.** A tree deeper than the longest path the kernel
takes, or a directory unreadable to root through FUSE, ends every walk incomplete; a directory of millions of
entries makes the engine hold them all at once. Each is "not judged" — the urgent condition, and linking
refused — never a pass. Reading by directory handle, as `find` did, would close the first.
- **A filesystem mounted only on use** (an automounted boot partition) makes the verdict "not judged yet" each
time it appears unwalked, until a walk covers it.
- **Part of the walk's cost escapes its priority**: Go's garbage collector runs on threads of normal priority,
and a software RAID's own threads do not carry the idle class to the disks.
- **ADR 0266 §3 item 10** still says "at most hourly" and "at most 15 minutes". On resolution it gains a
progressive insight naming this issue and the values above.
- **A later option, not built now:** a fanotify filesystem mark with `FAN_ATTRIB` (Linux 5.1 and later) on
every searched filesystem would report each change of mode or owner as it happens, turning the daily walk into
a true incremental search.
- **Resolution** of this core issue needs a replay in mesh-lab's register, or `replay-none:` with the reason.
## 2026-10-10 — the search shows its progress, and the operator can ask for a fresh one
- **The progress was kept and seen by nobody.** At 01:40 the operator asked how far the first walk on the
control-node had got. The walk kept its progress every minute beside the engine's state, but `root-free` said
only "not judged yet (search running), started 01:27", and no tool read further. The node-engine serves no
tools of its own, so the progress now travels where the verdict already does: the agent account's verdict
carries it as fields, and its reason gains one line that `root-free` repeats. The line holds filesystems done
of the total, the mount point being walked, the entries visited, the setuid- or setgid-root files found so far,
when the progress was last kept, and how long each finished filesystem took. Counts, times and mount points
only: the cursor and every file found are often under an account's home, and stay in the engine's state and
journal. The line moves at each checkpoint (once a minute) and is never part of what makes a verdict
"changed", so the journal does not repeat it every minute; the statement is said every minute anyway while
the account is not healthy. An entry is counted once across a resume, as it is kept with the cursor it
matches. The time a filesystem took counts only the time the engine walked it, not the time it was stopped.
- **A change by hand was not searched for until the next day.** The first complete search on the control-node
ended at about 01:48 and found two setuid-root programs no package owns, copies in the operator's home. When the
operator removes them by hand, no apply says so, and nothing searches again until the daily search. The
operator now asks for one at the controller's terminal: `nox node setuid-search <node>` on the control-node.
The controller signs the ask with the mesh's key, as it signs a hand-over, under a signing context of its own.
The engine throws away the last complete search and any walk under way or kept, and the next look starts a
full search. Until it completes, the verdict is "not judged yet", with its progress.
- **Why at the terminal alone, not a verb.** A search can never make a machine root-free wrongly: it judges only
once complete. But every ask throws a walk away. Asked again and again, it would keep the machine unjudged
for good and its disks busy, and whoever may call a verb includes agents. The operator needs the line only
after a change made by hand.
- **A find counts only where the agent account can execute it.** The operator asked how the agent account could
reach the two finds at all. It cannot: they sit in the operator's home, which is mode 0700. A setuid- or
setgid-root program no package owns now counts against an account only when that account can execute it.
Every directory from the root down must be searchable for it and the file executable for it, judged by its
uid and all its groups (owner, group or other bits as the kernel picks them). The judge errs towards a way:
a link on the path, a POSIX ACL on any component, or a mode it could not read counts as reachable.
The search still keeps every find. Reachability is read again at every judgement (one `lstat` per path
component), because the operator's account can open its own home without root and no search would see it.
A find in a home is said by count ("in an account's home"), never by path. Its path stays in the engine's
journal line that ends each search. On resolution, ADR 0266 §3 item 10 gains this as a progressive insight
beside the values above.
@@ -0,0 +1,67 @@
---
status: located
opened: 2026-10-10
located-in: [mesh-catalog modules/mesh-delivery (holder.go deliveryWalking), mesh-controller cmd/mesh-controller (upgrades.go SourceMoved, release_plan.go laterOnTheBranch)]
fixed-by:
amended-design: 03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md
---
# 362. Two catalogue merges seconds apart left their delivery waiting for a person
## Symptom
Shortly after midnight on 2026-10-10 (local time) two pull requests of the catalogue were merged
18 seconds apart: the claude-code change at 00:20:52, then the dunst change at 00:21:09. Both had passed
their checks.
`mesh-delivery.deliveries` then showed every delivery of the dunst pull request `superseded`, and the
claude-code delivery `held`, waiting for "a person: merged, and the controller opened no walk for it within
10m0s — nothing it holds follows that branch, or the merge was not heard: a person decides". The
controller's `plans` said of the walk for the catalogue's head, which carried both merges: "published; its
walk waits for mesh-delivery's word — `plans go … --why …` starts it by hand".
Both reasons were wrong. A walk had been opened, and it contained the held merge. Nothing gave it the word.
The operator's change sat undelivered for 58 minutes, until the operator's hand-act `mesh-controller.plans
go` started the walk. **No condition asked anyone to act in that time**: the hold is bounded at 24 hours
before `mesh-delivery.stalled` lists it, and the walk's own wait raised only a warning, at 00:51, which said
there was nothing to do and pointed at the ended delivery (the diagnosis's second entry; this sentence said
before that the walk's wait raised nothing).
## Why it is an issue
A merge that passed its checks must reach its nodes without a person, and a delivery that cannot must say
so loudly and truly. Here both failed: two ordinary merges close together, which happens whenever the
operator merges a batch, stopped the mesh's delivery silently, and the reason it gave pointed away from the
cause.
## Where it lives
The diagnosis ([01-diagnosis.md](01-diagnosis.md)) traces it to two parts that each behave as written and
disagree on what a walk's commit means:
- The controller plans a merge it hears after a later merge of the same branch at the later merge's commit,
and supersedes the earlier walk (the fix for
[issue 349](../349-a-merge-acted-on-late-superseded-the-merge-after-it-and-built-the-older-commit/00-report.md)).
So the claude-code merge got a walk at the dunst merge's commit.
- mesh-delivery finds the delivery a walk walks by its walk id or by the commit it was merged as. The new
walk matched the dunst delivery, which its first walk's supersession had already ended, so no live
delivery held the walk and nothing gave it its turn. The claude-code delivery, merged as a different
commit, never got a walk and was held after ten minutes.
## Open
- How mesh-delivery learns which merges a walk carries: the controller's walk record naming every merge it
answers (its commit and the commits planned into it), or mesh-delivery reading containment itself, as it
already does to decide *covered*.
- A superseded walk whose successor matches only final deliveries: whether the successor should be adopted
as a delivery of its own, so that something always holds a waiting walk.
- A walk waiting for mesh-delivery's word past a bound, and a delivery held because no walk was opened,
raise a condition to the operator instead of waiting silently (failure must be loud).
## Decided
The operator decided the model that removes this shape, not only its bookkeeping:
[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). Merges are collected in a rolling merge window and each batch is walked once, with at most one
commit per repository, one walk at a time. The bookkeeping that tied a walk to an ended delivery is repaired as
well, and every wait of this shape has a bound and a condition. It is designed in to-be 47, *The merge window
and the batch*.
@@ -0,0 +1,75 @@
# 362. Diagnosis
## 2026-10-10 — the trail
Times are local (UTC+2). Commits are the catalogue's.
**What the forge did.** The claude-code pull request was merged as 553b7191 at 00:20:52, and the dunst pull
request as 48bda475 at 00:21:09. 48bda475 is the branch's head and contains 553b7191.
**What the controller did** (its journal, 00:21:14 to 00:21:15):
1. It heard the dunst merge first and opened a walk at 48bda475 for dunst, waiting for mesh-delivery's word.
2. It then heard the claude-code merge, and printed "553b7191 was merged before 48bda475 (…, building): what
it moved is built from 48bda475, which contains it". This is the rule that answered
[issue 349](../349-a-merge-acted-on-late-superseded-the-merge-after-it-and-built-the-older-commit/00-report.md):
`SourceMoved` replaces the merge's commit and merge time with the newest planned merge's, when that merge
is later on the branch (`laterOnTheBranch`).
3. The second walk, also at 48bda475 and with the same merge time, superseded the first by when each was made
(`supersededBy`, `earlierOnTheBranch`), and carried claude-code and dunst. It waited for mesh-delivery's
word.
So every walk the controller opened was at 48bda475. None was at 553b7191.
**What mesh-delivery did** (`mesh-delivery.show` for both deliveries):
1. The dunst delivery (merged as 48bda475) took the first walk, matched by its commit, and became
`published` at 00:21:14.
2. The first walk's record came back superseded, and the table's `superseded` transition ended the dunst
delivery at 00:21:15 ("a newer delivery to the same trunk took over its walk").
3. The second walk was matched to a delivery by `deliveryWalking`, which looks first for a delivery holding
that walk id and then for any delivery whose `merged_as` is the walk's commit, final or not. That is the
dunst delivery again, already final. The walk was linked there, so it was never kept as unmatched and
never adopted as a delivery of its own. A turn (`turn`) is given only to a `published` or `delivering`
delivery, so nothing ever gave the second walk its word.
4. The claude-code delivery, merged as 553b7191, matched no walk. Ten minutes after its merge
(`unwalkedPast`) it was held for a person at 00:30:58, with a reason that names two causes, neither of
which happened.
After the operator's `plans go`, the second walk's machine steps (claude-code and dunst, each on a node) were recorded on the superseded dunst delivery, which confirms that mesh-delivery reads that walk as
the dunst delivery's.
**Why nothing was raised.** A delivery held for a person is bounded at 24 hours before
`mesh-delivery.stalled` lists it (`Bounds` in its table). The walk waiting for mesh-delivery's word is
counted against the controller's plan wait bound of 30 minutes for `status`, but no condition reached the
operator in the 58 minutes it waited. I did not trace why the controller's side stayed quiet; that belongs
to the fix.
## Ruled out
- **The merge was not heard.** The controller's journal shows it was heard and planned within 23 seconds.
- **Nothing follows that branch.** claude-code follows the catalogue's trunk; the second walk built it.
- **A failed check.** Both heads passed the merge gate and the repository's check.
## Located
Two parts, each correct by its own rule, disagree on what a walk's commit means. The controller (since issue
349) plans an earlier merge at a later commit that contains it; mesh-delivery assumes a walk's commit is the
merge commit of exactly one delivery. `located-in` names both. The fix is to make the walk say which merges
it answers, or to make mesh-delivery match by containment, and to let no waiting walk be held by nothing.
## 2026-10-10 — what the controller's side said
The first entry says no condition reached the operator in the 58 minutes. The controller's condition history
says more precisely what happened. S16 (a walk waiting for its delivery's word) raised
`plan.plan-1791584475080493383.waiting` at 00:51:32, thirty minutes after the walk opened, as its bound says.
It was a warning, its explanation was "Nothing for you to do … It becomes a question for you if it still waits
after 4 hours", and its summary sent the reader to "`mesh-delivery.show` for the delivery that landed as
48bda475", which is the dunst delivery the supersession had already ended. It cleared at 01:20:32, when the
operator's `plans go` started the walk.
So the controller's side was not silent; it was mild and pointed at the wrong delivery. S16 cannot tell a
group member waiting its turn from a walk nobody holds; only mesh-delivery knows that. That is why ADR 0276
decision 9 has mesh-delivery list a waiting walk no live delivery holds through `stalled` at once, and has
S16 name the merges a walk answers.
@@ -0,0 +1,64 @@
---
status: located
opened: 2026-10-10
located-in: [mesh-controller internal/builder (what a build says it was made from, and what an image compiling Go is handed), mesh-controller cmd/mesh-controller (the planner's mapping, touchedBy and readsFrom), mesh-controller internal/inventory (the packages edge), mesh-catalog modules/route-proxy and modules/build-agent (the package each recipe compiles)]
fixed-by:
amended-design:
---
# 363. ADR 0267 was decided and never built
## Symptom
[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)
(accepted 2026-10-08) decided that a merge moves only the artifacts whose build source it touches, and a
module only along build dependencies on artifacts. [To-be 47](../../03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md)
and [to-be 10](../../03-DESIGN/01-to-be/10-delivery.md) were amended to name it. Its consequences say "the
build handoff opens on mesh-controller and the catalogue".
On 2026-10-10 the operator took the rule as built and live. It is not:
- **The code**, at the controller's trunk on 2026-10-10: `mergeCandidates` and `readsFrom` still move a module
whose build reads a context on any merge to that repository, whatever the files; `touchedBy` still reads
every file of a repository as the module built from its root; `dependencies.go` still draws a `packages`
edge; the builder still fingerprints a context's whole tree. The planner tests still hold the rows tagged
*issue 338* as today's behaviour.
- **The plans.** Every merge to the controller's repository still plans the controller, the build seat's
holder and the route proxy, in three gated tiers, sent to every machine running them — for a change to the
controller's own command as for one to its README. This is
issue 338 (hq pull request #231, still open), unchanged.
- **The record.** No pull request in any repository names ADR 0267, and no issue tracks its build. ADR 0267's
*How this is checked* table names tests that do not exist.
## Why it is an issue
It is the pattern of [issue 354](../354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)
in its decision form: a record accepted, its designs amended, and the work it hands to code repositories
started by nobody. Everything a reader meets — the record, the two designs, the glossary's *build plan* —
describes the rule, and nothing says it is not running. The operator plans against a mesh that does not
exist: a controller merge costs three gates (about 15 minutes, against 4 to 5 for its own tier, as ADR 0267
measured) and recreates the route proxy on its node for changes that never touch its program.
Issue 354's check refuses a record that defers work without an issue. ADR 0267 defers nothing in words — it
says what is built and by whom — so that check does not see it. A decision handed to a code repository with
no pull request and no issue is the same silence.
## The fix
The build of ADR 0267, in the order its records require (each its own pull request, linked here):
1. **mesh-controller, the builder.** A build says its build source per repository: a Go program's import
closure (derived, never narrower than `go list -deps`), an archive's directory, an image's recipe and the
package it compiles (the new artifact field `compiles`). An image naming that package is handed its build
source and nothing else, so a recipe reading past it fails by name; its fingerprint is over that source.
Said only for a build of a commit on the trunk.
2. **mesh-controller, the planner.** A changed file moves the modules whose recorded build source holds it;
a module with none recorded, or with an earlier merge's build still pending, is read as before. The
`packages` edge is no longer drawn and neither widens nor orders a plan.
3. **mesh-catalog.** The route proxy and the build seat's holder name the package their recipes compile.
Merged after (1) is live: a builder that predates the field refuses the manifest.
What ADR 0267 decides and this does not build yet — artifact-grain edges in the inventory, a dependent left
out when its base is rebuilt to the same digest, declared build-source paths for an artifact without a
reader, and the catalogue check refusing a whole-repository build source — stays open here, each to be built
on its own pull request.
@@ -0,0 +1,48 @@
---
status: located
opened: 2026-10-10
located-in: [mesh-controller cmd/mesh-controller (check.go moduleCheckFor), mesh-controller internal/builder (check.go gateLayer)]
fixed-by:
amended-design:
---
# 364. The merge gate refuses a module that uses a seat another module declares
## Symptom
On 2026-10-10 the catalogue's pull request for the agent module's proposed instructions
(ADR 0274, still on its own pull request, hq 268, so not linked here)
added `uses: [operator-channel]` to the module's manifest, so the module could ask the operator. Its
`mesh/merge-gate` failed: "a manifest the change touches fails the module check: claude-code uses the seat
"operator-channel", which no module declares and the mesh does not define". Its `mesh/repo-check`, which runs the
same module check over every manifest of the catalogue, passed. The router's module declares that seat.
## Why it is an issue
The gate's first step runs the controller's `module check` over **the manifests the change touches** and nothing
else. The rules between manifests then run over that set, so a seat another module declares is "declared by no
module". The command's own documentation says the opposite: "A seat another module declares is unknown unless that
module's manifest is passed too. Both are printed as a note, not as a problem — a check that refused what it could
not see would teach people to ignore it." The code refuses it.
So **no module can start using a seat a module declares**, the operator channel's above all (ADR 0259 says any module
asks through it), and no channel can claim a kind of the channel bench, unless the pull request also touches the
declaring module's manifest. The searched literal text was in no record.
## Impact
Every module that becomes an asker is blocked at the gate. The first is the agent module's proposals (ADR 0274).
## Cause
`moduleCheckFor` counts every problem `CatalogueProblems` gives as a failure, and the gate gives it only the touched
manifests.
## Fix
The gate says it gives only some of a repository's manifests (an environment variable the judge reads); the module
check then says a seat none of them declares as a note. Given every manifest, as the catalogue's own check and
registration do, it stays a refusal. A judge that predates the variable ignores it and refuses as before, so the fix
takes effect once the controller that carries it is the one the mesh runs. In the controller's pull request for this
issue, with its tests: the module check over one manifest that uses a module's seat refuses it, and notes it when told
it was given only some.
@@ -0,0 +1,72 @@
---
status: open
opened: 2026-10-09
located-in: []
fixed-by:
amended-design:
---
# 375. An agent cannot put a commit on the forge
## Symptom
On 2026-10-09, in an agent session on the operator's laptop, local times:
- An agent finished a change to this repository's connectivity design on a branch, committed it in a
worktree, and ran `merge-check.sh`; all four checks passed.
- `git push` was not possible: this repository's clone on the laptop has its push URL set to the literal
`DISABLED`. The laptop's clones of the mesh's code repositories push normally. Who set it, when, and
why is not recorded anywhere this session could find.
- The forge's tools have no verb that pushes a branch or makes a commit. `gitea_api` refused
`POST /repos/{owner}/{repo}/diffpatch`, the forge's endpoint that applies a patch to a branch: *not a
write gitea_api makes* ([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)).
- The one file write `gitea_api` allows is the contents endpoint, which takes the **whole file**,
base64-encoded. The changed design document is about 90 KB, so the call would carry about 121 KB of
base64 that the agent must reproduce character for character. That is not a workable path.
The change waited for the operator to push it by hand, and so did this report.
*2026-10-10.* The push URL is no longer disabled, so `git push` over ssh works from the laptop again, with
the operator's key. Nobody recorded why it was disabled or why it was restored. The forge's tools are
unchanged: still 35, still no write that puts a commit on a branch.
## Why it is an issue
- **The mesh's own rule for a change cannot be followed by an agent.** Every change goes through a branch,
a pull request and a person's approval. An agent off the control node can make the branch locally and
open the pull request through `gitea_create_pull_request`, but it cannot put the commits between the two
on the forge.
- **Where no tool covers a need, none is worked around** ([ADR 0245](../../02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md)).
The only ways round are pushing to an explicit URL past the disabled one, or retyping a file as base64.
The first undoes a setting someone made on purpose. The second risks a commit that silently differs
from what was checked.
- **The control node already has an answer the laptop does not.** On the control node the agent account
holds a forge key of its own, so its pushes are its own (ADR 0266). An agent on any other machine has
no equivalent.
## Other gaps in the forge's tools
Found by listing the forge module's 35 tools on 2026-10-09. Only the first one blocks work. The others
can be done through `gitea_api`, which costs a raw call and a large answer each time.
| Gap | Today | Checked |
|---|---|---|
| **No push, commit or patch on a branch** | blocked, as above | yes |
| **No commit history or compare** | `GET …/commits` through `gitea_api`: about 100 lines of JSON for one commit, its author and committer printed in full each time. `gitea_contains` answers only whether one commit contains another. | yes |
| **No reading a pull request's reviews or line comments** | `gitea_list_comments` returns the conversation only. Reviews and comments on lines need `GET …/pulls/{n}/reviews` through `gitea_api`. An agent asked to address review feedback does not see it unless it knows to ask there. No *approve* tool is right, since approval is a person's. | yes |
| **No creating a branch** | `POST …/branches` through `gitea_api` | no |
| **No editing an issue** — title, body, labels | only close and reopen exist | no |
| **No listing a directory** | `gitea_get_file` reads one file | no |
| **No code search across repositories** | none | no |
*Checked* says whether this session called the raw endpoint and saw it answer. The rows marked *no* are
read from the tool list and the forge's API, not tried.
## Open
- Who disabled the push URL on the laptop's clone of this repository and who restored it, and whether
disabling it was meant to keep agents from pushing here or was a step in something else.
- Whether an agent off the control node should push with a forge key of its own, as on the control node,
or whether the forge module should take a commit as a patch and write it under the agent's account.
Today an agent off the control node pushes with the operator's key, so its pushes are not its own.
Either needs a decision record.