Compare commits

...
Author SHA1 Message Date
jochen c58f3dc765 ADR 0280: the resolver restarts itself when it stops answering, by the operator's word (issue 348)
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 ready: it delivers once merged
ADR 0240 rule 6 left a restart on health to a record of its own. The resolver's
failure cuts its machine off by name, so no healer in the controller can reach
it; the module repairs itself, braked, and says it as its health. To-be 48 and
ADR 0240 point to the exception.
2026-10-10 17:47:49 +02:00
mesh-admin d82dde3dca Merge pull request 'ADR 0278, to-be 53 §9: the issue tracker checks every new issue for a duplicate' (#281) from decisions/0278-the-issue-tracker-checks-each-new-issue-for-a-duplicate into main 2026-10-10 15:22:34 +00:00
jochen 5f2f520018 ADR 0278: the check's worst case is under the tool runner's two minutes, not one
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 17:21:39 +02:00
jochen f89c2c350d ADR 0278: the check as a whole takes up to a minute, kept alive by the tool runner (issue 387); one judgement at a time
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
Review found the two questions together exceed the consumer's ack wait, which the
tool runner did not extend; the record says so and names the fix it waits on.
2026-10-10 17:20:00 +02:00
jochen cf3065c660 ADR 0278, to-be 53 §9: the issue tracker checks every new issue for a duplicate
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The operator asked for duplicates to be consolidated, and better, when an issue is
opened rather than on a schedule. A judgement needs a model, and the mesh had no
channel for one; the agent module answers one question with no tools, under the
node's licence, and the tracker acts only on a clear duplicate, closing the newer
issue and keeping its report on the older one.
2026-10-10 17:04:27 +02:00
mesh-admin 894be9971f Merge pull request 'An issue's status lives in the issue tracker: playbook 03, cycle.py and the notes slice 3 of ADR 0275 asked for' (#280) from process/issue-status-lives-in-the-tracker into main 2026-10-10 14:17:46 +00:00
jochen 6d1310f1ea An issue's status lives in the issue tracker: playbook 03, cycle.py and the notes slice 3 of ADR 0275 asked for
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 15:54:32 +02:00
mesh-admin 1ea678c6bd Merge pull request 'ADR 0277: a trusted setting is proposed through a verb and set on the operator's warrant, and a module's own secret is asked for and typed at the desk' (#278) from decisions/0277-a-trusted-setting-is-proposed-and-set-on-the-operators-warrant into main 2026-10-10 12:50:19 +00:00
mesh-admin 76bb721cf4 Merge pull request 'Issue 362 resolved: ADR 0276 gains the own-path batch rule and a progressive insight, to-be 45 gains S18 and S19' (#277) from issues/362-resolved into main 2026-10-10 12:43:19 +00:00
jochen 3539446885 ADR 0277: the migrations are 0090 and 0091, and the code lands in two pull requests
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 14:42:47 +02:00
jochen 4d92d3872b ADR 0277: a trusted setting is proposed through a verb and set on the operator's warrant, and a module's own secret is asked for and typed at the desk
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The operator does not want to run manual commands on the control node. Issue 339 made every trusted
setting the terminal's alone, and a module's own secret the terminal's or the give verb's: each a line
an agent could only hand the operator to type. ADR 0274's pattern is extended: an agent proposes, the
operator approves on a channel that proves who answered, the controller sets the layer on the warrant
bound to the exact values shown. A secret is asked for by a verb and typed at the desk, bounded, the
prompt naming who asked. The designs 44, 52, 46 and 13, issue 339, ADR 0259 and 0266 and the glossary
are amended with dated notes.
2026-10-10 14:24:06 +02:00
jochen 952c984c34 Issue 362 resolved: ADR 0276 gains the own-path batch rule and a progressive insight, to-be 45 gains S18 and S19
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 14:14:56 +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
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
83 changed files with 5514 additions and 179 deletions
+11 -8
View File
@@ -23,14 +23,17 @@ result is not "nothing to learn" — it is the reason the next person repeats th
## Steps
1. Read `00-report.md`. Move `status:` to `diagnosing`.
2. Investigate. Write `01-diagnosis.md` in the same folder: the trail, **dated**, including
what was ruled out and how. Archaeology — which commit, which pull request, which date a
behaviour changed — is the most valuable content here.
3. When the owner is known, set `status: located` and fill `located-in:` with repositories or
modules from [`00-META/repos.md`](../../../00-META/repos.md).
4. On resolution: `status: resolved`, fill `fixed-by:`. If the root cause was a design gap, run
`hq-graduate` for the amendment and fill `amended-design:`.
1. Read the issue: `mesh_call issue-tracker.get` with its number, and `00-report.md` when it has a
folder. Move it to `diagnosing` with `issue-tracker.move`.
2. Investigate. The trail goes in `issue-tracker.note`, **dated** in its text, including what was
ruled out and how; for an issue with a folder, in `01-diagnosis.md` there as well. Archaeology —
which commit, which pull request, which date a behaviour changed — is the most valuable content.
3. When the owner is known, `issue-tracker.move` to `located` with `located-in`: entries of the
tracker's registered list of owners (repositories, and modules as `mesh-catalog modules/<module>`);
the tracker refuses an owner it does not know.
4. On resolution: `issue-tracker.move` to `resolved` with `fixed-by`, and `replay` or `replay-none`
for a core issue. If the root cause was a design gap, run `hq-graduate` for the amendment and give
`amended-design`. Nothing of this is written in the report's frontmatter.
## Rules
+28 -9
View File
@@ -21,27 +21,46 @@ 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
---
status: open
opened: YYYY-MM-DD
located-in: []
fixed-by:
amended-design:
---
```
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
then `# NNN. The title`, the title given to the tracker. **No status key**: the issue's status, owner and fix
live in the tracker (ADR 0275), and `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`,
`amended-design:`, `replay:` or `replay-none:` in a report opened after the import. Notes and links 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.
+6 -3
View File
@@ -16,12 +16,14 @@ generated on demand and never written back to disk.
| Research | `01-RESEARCH/NNN-*/00-overview.md` | `status` (`active` / `graduated` / `abandoned`), `initiated`, `touches`, `became` |
| Design | `03-DESIGN/**/*.md` (not READMEs) | `layer` (`as-is` / `to-be`), `status` (`designed` / `in-progress` / `implemented` / `abandoned`), `code`, `updated`, `decisions` |
| Decisions | `02-DECISIONS/NNNN-*.md` | `status` (`proposed` / `accepted` / `superseded`), `date`, `deciders`, `reconstructed`, `superseded-by`, `extends` |
| Issues | `04-ISSUES/NNN-*/00-report.md` | `status` (`open` / `diagnosing` / `located` / `resolved` / `wontfix`), `opened`, `located-in`, `fixed-by`, `amended-design` |
| Issues | the issue tracker, not a file: `mesh_call issue-tracker.list` (optionally `status`, `owner`, `phrase`) and `issue-tracker.get` | `status` (`open` / `diagnosing` / `located` / `resolved` / `wontfix`), `opened`, `located-in`, `fixed-by`, `amended-design`, `record` (the folder here, when it has one) |
## Steps
1. Read the frontmatter block from every file above — a grep across each tree is enough, no
need to load bodies.
need to load bodies — and the issues from the tracker. The `status:` keys the reports numbered
001 to 364 still carry are the import's snapshot of 2026-10-10 (ADR 0275), not where an issue
stands.
2. Render what was asked as Markdown tables. Group and sort sensibly. The **ADR index** is one
of these views, and it is the only place the list of records is shown
([ADR 0248](../../../02-DECISIONS/0248-the-decision-index-is-generated-when-it-is-read-and-never-stored.md)):
@@ -44,4 +46,5 @@ generated on demand and never written back to disk.
already drifted after a single addition, which is why it was removed, and the generated one
written into `02-DECISIONS/README.md` made every two open decisions conflict (issue 297), which
is why that went too. `index.py` fails if the list is stored there again.
- Do not infer status from prose. Trust only the frontmatter; if it is wrong, flag it.
- Do not infer status from prose. Trust only the frontmatter, and the tracker for an issue; if
either is wrong, flag it.
+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
+129 -38
View File
@@ -4,7 +4,7 @@
The knowledge flow (00-META/process/00-overview.md) says work moves idea -> research ->
decision -> to-be design -> code, and symptom -> issue -> diagnosis -> fix. Those are rules,
and a rule states how it is checked (AGENTS.md) -- this is how. Everything here reads only
frontmatter, because status lives in frontmatter and nowhere else.
frontmatter, because a status this repository holds lives in frontmatter and nowhere else.
What is enforced:
@@ -12,15 +12,15 @@ What is enforced:
known `status:`. A TO-BE doc names at least one decision (`decisions:`) -- no
design without a decision -- and once `in-progress` or `implemented` it names
its owning code (`code:`) -- no development without a design that says where.
issues a known `status:`; once `located`, `located-in:` names the owner;
once `resolved`, `fixed-by:` says what fixed it (prose counts --
"nothing, the capability existed" is an answer). And no two records share a
number -- the number is how a record is cited.
replays a core issue -- one opened from 2026-10-07 whose `located-in:` names a core repository --
resolves only with `replay:` (the replay's id in mesh-lab's replays register) or
`replay-none:` (why no replay is possible): ADR 0237, to-be 45 §9. A replay is what
proves a fix fails before and passes after; without one, a fixed class comes back
through a different door (research 031: four such chains in one week).
issues every report has frontmatter, and no two records share a number -- the number is
how a record is cited. An issue's status, owner, fix and replay live in the issue
tracker since the import of slice 3 (ADR 0275, to-be 53), where `issue-tracker.move`
refuses at the write what this check refused at merge until then: a status outside
the five, `located` without an owner, `resolved` without a fix, a core issue
resolved without its replay (ADR 0237), `wontfix` without a reason. A report opened
after the import carries none of those keys -- the tracker holds them, and two
places holding one fact drift. The reports opened before keep theirs as the dated
snapshot the import took; they are not judged, and not kept up to date.
research a known `status:`; a `graduated` overview says what it `became:`, and every
target it names exists.
decisions every accepted record is REACHABLE from the cycle: cited by a design doc's
@@ -28,9 +28,8 @@ What is enforced:
record's extends/supersedes chain. A decision nothing points at is one nobody will
find by following pointers -- which is how records go stale in people's heads.
Deliberately NOT enforced: `resolved` issues may leave `located-in` empty (a symptom that
turned out not to be a defect has no owner), and as-is docs need no decisions (they
describe what exists, not what was decided).
Deliberately NOT enforced: as-is docs need no decisions (they describe what exists, not what
was decided).
python3 00-META/checks/cycle.py
"""
@@ -43,18 +42,83 @@ import sys
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abandoned"}
ISSUE_STATUSES = {"open", "diagnosing", "located", "resolved", "wontfix"}
RESEARCH_STATUSES = {"active", "graduated", "abandoned"}
# The core, as to-be 45 names it, by where its code lives: the controller, the node-engine, the node
# tools and the console, the SDK's loops, and the catalogue's bus, forge (the announcer of merges) and
# build agent. A `located-in:` entry naming any of these makes an issue a core issue.
CORE = re.compile(r"\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|"
r"mesh-catalog\s+modules/(nats|gitea|build-agent))\b")
# The keys an issue report carried while its status lived here, and the day from which a report carries
# none of them: the issue tracker holds them (ADR 0275, slice 3 of to-be 53), and `issue-tracker.move`
# enforces the cycle's rules on them at the write. A report opened on or after this day with one of these
# keys is a status written in two places. The reports opened before keep theirs as the import's snapshot.
STATUS_IN_TRACKER_FROM = "2026-10-11"
TRACKER_KEYS = ("status", "located-in", "fixed-by", "amended-design", "replay", "replay-none")
# The day the rule began (ADR 0237): issues opened before it are not held to it.
REPLAYS_FROM = "2026-10-07"
REPLAY_ID = re.compile(r"^R\d+\b")
# A decision that defers work names the issue that tracks it (issue 354). ADR 0244 left the code
# renames of the node-engine and the tool runner "for a later rename", with no issue, owner or date;
# two days later the operator believed them done, and nothing in the record said otherwise. A deferral
# nothing tracks is indistinguishable from work that was done. Held to it: an accepted record dated
# from DEFERRALS_FROM, and an older one corrected under the rule (a progressive insight dated from then
# on): an edit made under the rule is held to it. The phrases are the ones records use to put work off;
# "for later" alone is not one, because records say "never queued for later" and "too narrow for later".
# The tracking issue is named as tracking it, beside the deferral or in a dated insight in the same section
# or quoting it (ISSUE_REF, covers).
DEFERRALS_FROM = "2026-10-09"
DEFERRAL = re.compile(r"(?i)until (?:it is |they are |the code (?:is )?)?renamed|until the code rename|"
r"\ba later rename|not renamed here|identifier to rename|"
r"left (?:for|to) (?:a )?later|not decided here|\bpostponed\b|future work|"
r"\bdeferred\b|later work|not built here|\ba later change")
# The issue that tracks a deferral is named as tracking it -- "tracked by issue N", or "issue N tracks" --
# because a record cites issues for many reasons, and an issue cited as context beside a deferral (ADR 0273
# names issue 340 in the very sentence that leaves a channel undecided) tracks nothing.
ISSUE_REF = re.compile(r"(?i)tracked (?:by|in|as)\s+\[?issue (\d{3})|\[?issue (\d{3})\]?(?:\([^)]*\))?\s+tracks\b")
INSIGHT = re.compile(r"\*\*Progressive insights?[ \t]*[—–-][ \t]*(\d{4}-\d{2}-\d{2})")
def paragraphs(path):
"""A record's body as (first line number, section heading, chapter, text) paragraphs: frontmatter and fenced
code left out, a block quote's markers stripped, so a quoted insight reads as the paragraph it is. The
section is the nearest heading above the paragraph; a heading is its own paragraph and its own section.
The chapter is the nearest `#` or `##` heading, which a `###` subheading inside it does not change."""
text = open(path, encoding="utf-8").read()
m = re.match(r"^---\n.*?\n---\n?", text, re.S)
body, first = (text[m.end():], text[: m.end()].count("\n") + 1) if m else (text, 1)
body = re.sub(r"(?ms)^\s*```.*?^\s*```", lambda x: re.sub(r"[^\n]", " ", x.group(0)), body)
out, line, section, chapter = [], first, "", ""
for chunk in body.split("\n\n"):
stripped = "\n".join(re.sub(r"^\s*>\s?", "", l) for l in chunk.split("\n")).strip()
if stripped:
heading = re.match(r"^#{1,6}\s+(.*)", stripped)
if heading:
section = heading.group(1).strip()
if len(stripped) - len(stripped.lstrip("#")) <= 2:
chapter = section
# A list's top-level items are units of their own: a Consequences list is one paragraph, and an
# issue named in one item tracks nothing in the next.
items = re.split(r"\n(?=[-*] |\d+\. )", stripped)
at = line + chunk[: len(chunk) - len(chunk.lstrip())].count("\n")
for item in items:
out.append((at, section, chapter, item))
at += item.count("\n") + 1
line += chunk.count("\n") + 2
return out
QUOTE = re.compile(r"\"([^\"]{12,})\"|\u201c([^\u201d]{12,})\u201d")
def flat(text):
"""Text compared for a quotation: markup and case dropped, whitespace collapsed."""
return re.sub(r"\s+", " ", re.sub(r"[*`_]", "", text)).strip().lower()
def covers(insight_section, insight_text, section, text):
"""An insight speaks about a deferral paragraph when it stands in the same section, or quotes it."""
if insight_section == section:
return True
body = flat(text)
return any(flat(a or b) in body for a, b in QUOTE.findall(insight_text))
def issue_refs(text, issues):
return {n for a, b in ISSUE_REF.findall(text) for n in (a, b) if n and n in issues}
def rel(path):
@@ -162,28 +226,23 @@ def main():
else:
seen_records[number] = name
# Since the import of slice 3 (ADR 0275), an issue's status, owner, fix and replay live in the issue
# tracker, and its `move` refuses at the write what this check refused at merge. A report opened from
# STATUS_IN_TRACKER_FROM carries none of those keys; one opened before keeps the import's snapshot.
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
front = frontmatter(path)
if front is None:
bad(path, "no frontmatter")
continue
status = front.get("status")
if status not in ISSUE_STATUSES:
bad(path, "status %r is not one of %s" % (status, sorted(ISSUE_STATUSES)))
if status in ("located", "resolved") and not listy(front, "located-in"):
bad(path, "status %s but located-in is empty" % status)
if status == "resolved" and not listy(front, "fixed-by"):
bad(path, "status %s but fixed-by says nothing" % status)
# A core issue resolves with its replay, or with why none is possible (ADR 0237).
replay = " ".join(listy(front, "replay"))
if replay and not REPLAY_ID.match(replay):
bad(path, "replay: %r names no replay -- an id from mesh-lab's replays register, R<issue>" % replay)
core = any(CORE.search(entry) for entry in listy(front, "located-in"))
opened = str(front.get("opened") or "")
if (status == "resolved" and core and opened >= REPLAYS_FROM and not replay
and not " ".join(listy(front, "replay-none")).strip()):
bad(path, "a core issue resolved with no replay: name it in `replay:` (mesh-lab replays "
"register.go) or say in `replay-none:` why none is possible (ADR 0237)")
if not opened:
bad(path, "opened: says nothing -- the day the issue was opened, YYYY-MM-DD")
continue
carried = [key for key in TRACKER_KEYS if key in front]
if opened >= STATUS_IN_TRACKER_FROM and carried:
bad(path, "carries %s, but an issue's status, owner, fix and replay live in the issue tracker "
"(ADR 0275): move it with `issue-tracker.move`, and keep no copy here"
% ", ".join("`%s:`" % key for key in carried))
# ---- research ----------------------------------------------------------------------
for path in sorted(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))):
@@ -233,6 +292,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
+57 -11
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~~
@@ -277,7 +279,9 @@ and the operator act. **Recorded by** the controller's `identity` and `licences`
- **proof** — what an authorising answer carries: a verified sender, a one-time code, a security key's
touch (ADR 0234). How much proof a kind of ask needs is its **assurance level**.
- **controller's terminal** — a command line the controller runs as typed by its operator, not asked through a
verb, so a change that is the terminal's alone is taken (issue 339, issue 340). In the controller's code it is a
verb, so a change that is the terminal's alone is taken (issue 339, issue 340) — or, since
[ADR 0277](../02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md),
proposed through a verb and set on the operator's warrant. In the controller's code it is a
line run without `MESH_VERB`. The operator reaches it with `mesh-cli` from their own account on a node where no
agent can run as that account: the control-node, and a node whose agents run under an account of their own
([ADR 0272](../02-DECISIONS/0272-the-operator-reaches-the-controller-with-mesh-cli-on-every-node-and-a-node-is-the-terminal-only-where-no-agent-runs-as-its-operator.md)).
@@ -287,7 +291,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 +328,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 +349,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.
@@ -466,7 +479,11 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 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
@@ -486,6 +503,10 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272.
- **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).
- **proposal** — a change an agent, or anyone the bus admits, puts forward through a verb and that takes
effect only on the operator's warrant, bound to the exact thing shown: a shared instruction section (ADR
0274), a trusted setting's layer ([ADR 0277](../02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md)).
A proposal changes nothing by itself; declined, expired or refused, it is discarded.
- **operator message / input** — what arrives on `intake`, once the router has checked the sender
against its own list of the operator's identities: a **trusted** one is an operator message,
addressed to an agent by `@name` or thread or else to the **responder** (`@mesh`, the router's own
@@ -495,11 +516,24 @@ asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 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
@@ -508,8 +542,18 @@ 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)
- **duplicate check** — the issue tracker's judgement, when an issue is opened, of whether it asks a question an
older issue not resolved already asks; a certain duplicate is closed `wontfix` with its report kept on the older
issue ([ADR 0278](../02-DECISIONS/0278-the-issue-tracker-checks-every-new-issue-for-a-duplicate-through-a-judgement-the-agent-makes-without-tools.md)).
Its pass over the backlog is the verb `groom`.
- **hq check** — one of `00-META/checks/`, run by `merge-check.sh` as this repository's check.
## Homonyms
@@ -523,7 +567,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 |
@@ -556,6 +600,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 | — |
+11 -6
View File
@@ -60,17 +60,22 @@ the expensive half.
The flow above is a rule, and a rule states how it is checked:
[`00-META/checks/cycle.py`](../checks/cycle.py) refuses a to-be design that names no
decision, an `in-progress`/`implemented` design that names no owning code, an issue marked
`located`/`fixed` with no owner or `fixed`/`resolved` with no fix, and a `graduated`
decision, an `in-progress`/`implemented` design that names no owning code, two issue folders
sharing a number or an issue report carrying a status key, and a `graduated`
research overview that does not say what it became. Run it with `records.py`, `index.py` and
`words.py` before any HQ merge (`sh merge-check.sh` runs all four). What the checks cannot see — that code work actually started from a
`words.py` before any HQ merge (`sh merge-check.sh` runs all four). The issue rules the check
once held — `located` with an owner, `resolved` with a fix, a core issue with its replay — are
refused at the write by the issue tracker's `move`
([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)). What the checks cannot see — that code work actually started from a
handoff — is held by playbooks [04](04-build-handoff.md) and [07](07-feature-branches.md):
a feature branch exists because a design or an issue sent it.
## Status lives in frontmatter
## Status lives in frontmatter, an issue's in the tracker
Research overviews, design docs, issue reports and decision records each carry their status as
YAML frontmatter (schemas in the section READMEs and playbooks). There are **no central status
Research overviews, design docs and decision records each carry their status as
YAML frontmatter (schemas in the section READMEs and playbooks). An issue's status lives in the
issue tracker, which refuses a wrong status at the write, and its report here carries only
the day it was opened (playbook [03](03-issues.md)). There are **no central status
files** and no decision ledger. **Every decision is a record in
[`02-DECISIONS`](../../02-DECISIONS/)** — if it is worth recording it is worth a record, and if
it is not worth a record it is not recorded. Cross-cutting views, the decision index included,
+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
+59 -22
View File
@@ -19,46 +19,83 @@ 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 and the status
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.
**The tracker is the truth for every issue's status, owner, fix and links**, whether or not the issue has a folder
here. `issue-tracker.get NNN` says where an issue stands; `issue-tracker.list` says what is open. Nothing here
says it: a report's frontmatter carries no status.
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 folder holds the report and the diagnosis; the tracker holds everything else.
- **Wholly in the tracker** — every other issue. Nothing is written here.
**The reports written before the tracker** (the folders numbered 001 to 364) still carry `status:`, `located-in:`,
`fixed-by:`, `amended-design:`, `replay:` and `replay-none:` in their frontmatter. Those keys are the snapshot the
import of slice 3 took from `main` on the day it ran (the note in ADR 0275 says which day and which commit), and
they are not kept up to date: the tracker is. They are left in place because they are the record of what was known
then, reviewed in pull requests at the time, and because removing 361 keys would say nothing the tracker does not.
Nobody edits them.
## 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
---
status: open
opened: YYYY-MM-DD
located-in: [] # owning repo(s)/module(s), filled by diagnosis
fixed-by: # PR or commit reference, filled at resolution
amended-design: # design doc path, when the root cause was a design gap
---
```
Then the symptom **as observed**, in plain terms, with the evidence that it happened.
2. Investigate in `01-diagnosis.md` in the same folder — the trail, dated, including what was
ruled out. Move `status:` to `diagnosing`, then `located` once the owner is known.
3. Resolve. Set `status: resolved`, fill `fixed-by:`, and if the root cause was a design gap,
run playbook [02](02-graduation.md) and fill `amended-design:`. **A core issue** — its `located-in`
Then `# NNN. The title`, the title as the tracker holds it, and the symptom as observed, as in the tracker's
report. **No status key**: `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`, `amended-design:`,
`replay:` or `replay-none:` in a report opened after the import, because the tracker holds them and two places
holding one fact drift.
**An issue wholly in the tracker** is done at this step.
2. Investigate. The trail goes in `issue-tracker.note`, and, for a folder-backed issue, in `01-diagnosis.md` in the
same folder as well — dated, including what was ruled out. The status moves through `issue-tracker.move`:
to `diagnosing`, then to `located` with `located-in`, which the tracker refuses without a registered owner.
3. Resolve. `issue-tracker.move` to `resolved` with `fixed-by`, and, if the root cause was a design gap,
run playbook [02](02-graduation.md) and give `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).
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));
`issue-tracker.move` refuses the move without it. `wontfix` needs a `reason`.
## Rules
- Closed issues are never deleted — they are the mesh's symptom-to-component memory.
- `fixed-by:` names something that will still exist: a commit or a pull request, never a branch. A
- `fixed-by` names something that will still exist: a commit or a pull request, never a branch. A
branch is deleted when it merges, so a branch name there is a pointer that resolves to nothing by
the time anybody follows it.
- 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.
- `wontfix` is legitimate and requires a sentence saying why; the tracker refuses it without one.
- **A rule states how it is checked.** The cycle's rules on an issue — a status one of the five, `located` with an
owner, `resolved` with a fix, a core issue with its replay, `wontfix` with a reason — are refused at the write by
`issue-tracker.move` (mesh-catalog `modules/mesh-issues`, its table test over the rules). `cycle.py` checks what
this repository still holds: no two folders share a number, every report has frontmatter, and a report opened
after the import carries no status key.
+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 |
@@ -114,6 +114,16 @@ documents had failed.
**Every document carries its state in YAML frontmatter** — research overviews, design documents,
decision records, issue reports.
> **Progressive insight — 2026-10-10.** For issue reports this stopped holding the day slice 3 of
> [ADR 0275](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)
> landed: an issue's status, owner and fix live in the issue tracker, a module of the mesh, which refuses
> a wrong status at the write; a report here carries the day it was opened and nothing of its status, and
> `cycle.py` refuses a status key in a report opened after the import. The reports written before keep
> their keys as a dated snapshot. The reason stands exactly as written — one place, never two — and the
> one place for an issue is now the tracker, because a file every branch may edit was the place where
> numbers collided and statuses were hard to see. Research overviews, design documents and decision
> records carry their state here as before.
**There are no central status files.** Every cross-cutting view — a status matrix, a decision
index, an open-issue list — is generated from frontmatter when asked for, never written to disk.
Two places holding one fact drift, and the written one wins by being closer to hand.
@@ -46,6 +46,16 @@ The development cycle is enforced mechanically, to the extent frontmatter can ca
> refuses it now. The decision here stands exactly as written: this is one more thing frontmatter
> and file names can carry, found by its absence rather than by reasoning.
> **Progressive insight — 2026-10-10.** The third rule above, no owner-less diagnosis and no fix-less
> fix, and the replay rule `cycle.py` took on under ADR 0237, moved out of `cycle.py` the day slice 3 of
> [ADR 0275](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)
> landed: an issue's status lives in the issue tracker, whose `move` refuses at the write what the
> check refused at merge — `located` without an owner, `resolved` without a fix, a core issue without
> its replay, `wontfix` without a reason. Enforced mechanically still, one step earlier, in the one
> place the status is. `cycle.py` keeps the fifth rule, no two records answering to one number, and
> refuses a status key in a report opened after the import. The decision stands: the cycle is checked,
> not trusted; which program checks each rule is a fact that moved.
[`00-META/checks/cycle.py`](../00-META/checks/cycle.py) refuses violations, beside `records.py`
and `index.py`; all three run before any merge here. What frontmatter cannot see — that code work
actually started from a handoff — remains held by playbooks 04 and 07: a feature branch exists
@@ -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
@@ -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
@@ -181,6 +181,12 @@ gate waits rather than fails. What a consumer finds while its provider is health
reaches a person through the operator's conversation, or a healer; a healer that restarts on health is its
own decision.
> **The mechanism changed — 2026-10-10, by ADR 0280.** Rule 6 still stands for the node-engine and for
> every module but one: the dnsmasq module restarts its own resolver when the resolver stops answering,
> braked and said as its health, by the operator's word
> ([ADR 0280](0280-the-resolver-restarts-itself-when-it-stops-answering-braked-and-said-as-its-health.md)).
> Any other restart on health still needs its own record.
**7. A declaration is proved before it is trusted.** `module check` refuses a `health` field that names an
endpoint the module does not declare, an interval, timeout or count outside its bounds, or a tool the module
does not serve. A bed from mesh-lab, run by the catalogue's check on the build seat, starts every resource
@@ -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
@@ -508,6 +508,13 @@ with the router not knowing its own machine, it approved. Now:
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.
> **The mechanism changed — 2026-10-10, by [ADR 0277](0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md).** The same prompt is opened by the
> controller's `secret-ask` verb from anywhere on the mesh, with the desk the module's machine unless named,
> and `give` composes its line. It is bounded (one open ask per secret, three an hour, read from the store
> before the prompt opens) and the prompt names who asked, from the bus's word on the caller alone. What
> stands: the value is typed at the desk, sealed to the one call, never in an argument, a log or an event;
> a trusted party's secret is still the terminal's.
**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.
@@ -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
@@ -257,6 +262,11 @@ here:
which seals to the machine that uses the secret, never to a key the caller chooses. **The `token` verb is
refused too**, because it handed the joining token to its caller. Enrolling a machine is now done at the
controller's terminal, by the operator;
> **The mechanism changed — 2026-10-10, by [ADR 0277](0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md).** Beside `secret rotate`, one other
> `secret` line is run through a verb: `secret ask <node> <module> <name> [--at <desk>]`, which carries no
> value and answers none — the operator types it at the desk's hidden prompt. And `settings proposals`
> joins the generic verb's reads; `settings propose` is the settings verb's. Everything else stands.
- `claude_code_add_api_key` read a caller's file as the operator, sealed it away and deleted it. It now takes
only a file under the agent's own home, reached through no symbolic link, read as the agent, at most 4 KiB,
holding an Anthropic API key. The licence manager refuses a key of another shape too;
@@ -379,6 +389,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.
@@ -154,6 +154,11 @@ The table of ADR 0239 decision 2 gains these rows:
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
@@ -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,183 @@
---
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.
> **Progressive insight — 2026-10-10, slice 3.** The import is built: `issue-tracker.import` reads every issue
> folder through the records module over the bus, at the commit its checkout stands at, and writes each as an issue
> at its own number — create-if-absent on the key, the counter untouched, never an overwrite, a second run changing
> nothing and saying so; `move` no longer refuses an issue with a folder. Its dry run read `main` at commit
> `1ea678c6bd48f62a45db3b5e4b21b9c46867d31a` on 2026-10-10: 361 folders, all readable — 27 `open`, 1 `diagnosing`,
> 48 `located`, 284 `resolved`, 1 `wontfix` — and 29 `located-in` entries naming no registered owner, kept as
> written and marked unverified (issue 366 owns the list). The operator runs the import once after the rollout; the
> commit it read is on every imported issue (`imported.commit`) and in its `imported` events, where the mesh keeps
> its own truth. Two facts differ from §8 as written. **The status keys stay in the reports**: removing 361 keys
> would say nothing the tracker does not, and lose the record of what was known when; they are the dated snapshot
> of that day, not kept up to date, and `cycle.py` refuses them in a report opened from 2026-10-11 on. **The four
> consolidation passes and the audit of `fixed-by`** (to-be 53 §8) are not in the import: each is a list the
> operator confirms before it is applied, done through `move`, `link` and `note` on the imported issues, not by
> the import, which only ever creates. 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. (Since
slice 3's pull request, dated in the insight above: every issue is whole in the tracker.)
- [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,235 @@
---
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.
> **Decided during the build, 2026-10-10** (on the operator's delegation). Decision 2 batches every merge of the
> window together, and decision 4 starts a walk on the controller's own path without mesh-delivery's word. A
> batch holding both a merge on that path and a catalogue merge would therefore start the catalogue's deliveries
> without their word: their turn in a delivery group, their holds, and the order of decision 5 would be skipped.
> So **a merge on the controller's own path never shares a batch with a merge that waits for the delivery's
> word.** Such a merge goes into a batch of its own, with its own window; the open catalogue batch closes and is
> cut by the normal rules, and queues behind the own-path walk like any other. Catalogue merges heard during the
> own-path batch's window join the next catalogue batch. An own-path walk being cut folds no catalogue walk that
> waits for its word: that walk's merges are batched again behind it, and the walk names that batch as the one
> that took it over, so no catalogue delivery ever skips its turn. Checked by the controller's test
> `TestAMergeOnTheControllersPathNeverSharesABatch`.
## 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.
> **Progressive insight — 2026-10-10.** The controller has no consolidated schema file: a fresh install runs its
> numbered migrations from the first, embedded in the build, so the numbered migration (0089) is the whole of
> the schema change. The bullet above said "as a numbered migration and in the consolidated schema"; that second
> half describes mesh-delivery's kind of store, not the controller's. The decision stands.
- **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.
@@ -0,0 +1,260 @@
---
topic: what runs on it
status: accepted
date: 2026-10-10
deciders: jochen
reconstructed: false
extends: 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
---
# 277. A trusted setting is proposed through a verb and set on the operator's warrant, and a module's own secret is asked for and typed at the desk
## Context
[Issue 339](../04-ISSUES/339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md) found that any caller
of the controller's `settings` verb could have the node-engine hand a system directory to an account of the
caller's choice, and its fix made every **trusted setting** the controller's terminal's alone: `places` and
`accesses`, every key a provider serves, every setting a trusted file asks for, and the whole layer of a module
with a trusted mergeable file ([issue 340](../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md)).
The controller refuses them through any verb: "set at the controller's terminal only … whoever may call a verb
includes agents". A module's own secret issued outside the mesh is given the same way: `secret accept` at the
terminal, or the desk prompt the `give` verb opens ([ADR 0259](0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md)
§10, step 7).
That was right for the hole it closed and wrong for the operator's day. On 2026-10-10 the operator said: "you
should modify the settings yourself, I don't want to run manual commands on novox". The case at hand: the
`mounts` module's `sources` on the gaming machine's client, a trusted setting (a mount point on the machine,
asked for by a trusted file), and its `smb-credentials`, a secret issued outside the mesh. Each was a line the
agent could only hand the operator to type at the control node.
[ADR 0274](0274-an-agent-proposes-a-shared-instruction-section-and-every-node-takes-it-only-on-the-operators-warrant-which-it-proves-itself.md)
established the pattern that removes the terminal from the loop without giving the decision to an agent: an
agent **proposes**, the operator **approves** on a channel that proves who answered (Telegram, placed where no
agent can become root), and the act is performed on the **warrant** — the router's record of the choice, bound
to the exact thing shown. The controller already acts on warrants for its own conditions (Release, Stop,
Restart, a rehearsal), claims each once, and holds the act to the digest the option bound.
The record was searched first ("settings propose warrant", "trusted setting approve", "secret ask desk
prompt"): nothing beside ADR 0274, issue 339's rule, ADR 0259 §10 and the operator's direction.
**Measured in the controller's code on 2026-10-10.** The `settings set` command is the one place a layer is
written; the terminal rule is keyed on the verb named in the process's environment. The `asked` bucket on the
bus is written by the controller alone, so a record there, unlike the agent module's `proposals` state (ADR
0274 §3), proves who kept it. A layer carries when it was set and not who set it. The router's content rule
refuses a path, an address or a secret's shape in any ask's words, and `sources` holds both a path and an
address. The desk's prompt (`node-launcher.secret`) is written by the launcher from the module's, the secret's
and the machine's names, says the controller asks, and takes no words of the caller's; the bus lets only the
controller ask it. Nothing bounded how often it could be asked.
### 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 layer is the mesh's record, set by the controller on a warrant it checked,
never a value an agent wrote; who approved it is kept beside it.
- **Effect — "when something genuinely needs a decision, you are asked, with the context".** The operator reads
the module, the machine and the exact change on their phone, and taps.
## Considered Options
### Who may set a trusted setting
1. **The controller's terminal alone** (issue 339's rule). Safe, and kept as one of two ways. Alone, every
placement and every mount is a trip to the control node, and an agent can only describe what to type.
2. **Let verbs set trusted settings outright**, trusting the caller. Rejected: an agent would then decide what
root mounts and owns on a machine, which is the hole issue 339 closed.
3. **An agent proposes through a verb; the operator approves on a channel that proves who answered; the
controller sets the layer on the warrant.** Chosen, beside 1.
### Who performs the set
1. **The verb's process, on hearing the warrant.** Rejected: a verb's process is short-lived and does not hear
the router's word; the serving controller is the asker that holds its asks and claims each once.
2. **The serving controller, in-process, as `settings set` at the terminal does.** Chosen. The same judgement
(the module's definition, a line break, a port per machine, a removal meant or refused), the same history,
the same words.
### What the operator reads
1. **The exact values, always.** Rejected: the router's content rule refuses a path, an address or a secret's
shape in any message that leaves the mesh, and a mount's source is both a path and an address.
2. **A fingerprint only.** Rejected: nobody approves what they cannot read.
3. **Every key with its exact new value where every word may leave the mesh; where not, the value in its own
shape with the part that may not leave replaced by a marker (‹address›, ‹path›, ‹withheld›), the layer's
fingerprint, and where the whole is read.** Chosen. "recalbox=smb://host/share@/mnt/recalbox" is shown as
"recalbox=‹address›@‹path›", and `settings proposals <id>` shows it whole, with the same fingerprint.
### Where the record of the set is kept
1. **A seat event of the controller's own**, beside the hand-act log. Rejected for now: nothing consumes it, and
the installer's first user list in the node-engine's repository names every controller event one by one, so
it would cost a node-engine change and rollout for a fact without a reader.
2. **The hand-act log (on the bus), the layer's own record of who approved it, and the router's edit of the
ask on every channel to its outcome.** Chosen. The warrant is recorded under the verb `warrant`, which the
hand-act log counts as a person's decision; `settings` says "approved by the operator via telegram at …".
### How a module's own secret is asked for
1. **Through a verb's argument, or on Telegram.** Rejected: a verb's caller may be an agent, and a value typed
on Telegram passes a bot's server. A secret's value is typed where no agent and no outside party reads it.
2. **The desk prompt, as `give` opens it, asked for by a verb from anywhere, bounded, naming who asked.**
Chosen. The value is typed at the desk, sealed to the one call, sealed again to the module's machine, and
never in an argument, a log or an event.
## Decision
### 1. A trusted setting may be proposed through a verb, by anyone the bus admits
The controller's `settings` verb gains three arguments: `propose` (with `values`, or `clear`, and `replace` as
for a set), `proposals` and `proposal`. The command lines are `settings propose <module> <values | --clear>
[--node <node>] [--replace]` and `settings proposals [<id>]`, the operator's as `nox settings propose …`. A
proposal is for any key, the trusted ones included; `settings set` through a verb still refuses a trusted
setting (issue 339), and its refusal now names the proposal as the way.
A proposal **changes nothing**. It is kept in the controller's own asks on the bus (the `asked` bucket, which
the controller alone writes), with the module, the machine, the values whole, whether the layer is cleared or
a removal meant, the layer as it stood when proposed, who proposed it (the bus's word on the caller) and when,
and the digests of the values and of the layer they replace. It is judged as `settings set` judges before the
operator is asked, so no ask is raised for a layer the mesh would refuse, and a removal not marked `--replace`
is refused as [ADR 0217](0217-no-change-to-a-machine-takes-effect-unseen.md) refuses it at the terminal.
### 2. The ask
One ask per proposal, published under the controller's name on the `operator-channel` seat:
- **headline**: "Set mounts on shanks?" ("Clear …?", "… on the whole mesh?"), shortened when the names make
it longer than the router allows;
- **explanation**: the module and the machine; who proposed it and when; then the change against the layer as
it stands — "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the
count of keys unchanged — each value exact where every word of it may leave the mesh, else in its shape
with ‹address›, ‹path› or ‹withheld› in place of what may not; the layer's **fingerprint** (the first 24
hexadecimal digits of its digest, in groups of four); when a part is not shown, where the whole is read
(`settings proposals <id>`, through the mesh MCP server or `mesh-cli`), whose fingerprint must be the one on
the phone; and that, approved, the layer is set at once and the machine takes it at its next push;
- **two options, Approve and Decline, both at the level approve**, as the controller's rehearsal asks, so only a
channel that proves who answered carries either. Each binds the digest of its act: the proposal's id, the
answer, the module, the machine, the digest of the exact values, the digest of the layer they replace,
whether the removal is meant, whether it is a clear, who proposed it and when;
- **expiry**: a day less ten minutes after it was proposed; at expiry the proposal is discarded. An ask that
authorises never defaults (ADR 0259);
- **about**: the module and the machine, so a newer proposal for the same layer replaces an older one still
open, and the older is discarded.
### 3. The serving controller sets the layer on the warrant, and on nothing else
The controller acts on the warrant as on any other of its asks (ADR 0259 §6): only for an ask it holds, only
on a warrant whose ask digest is the one it kept, claimed once by compare-and-set so a warrant heard twice, or
replayed, changes nothing; only for an option the ask offered, at that option's level, before the ask expired.
Then, before anything is set:
1. the act about to be performed must be the one the chosen option bound, and **the values kept in the
record must digest to the digest in that act**: a record whose values, or whose layer-as-it-stood, changed
after the ask is refused and nothing is set;
2. the layer must still be the one the operator was shown the change against (to-be 46 §10, step 7): a layer
changed since, at the terminal or on another warrant, refuses the act, and the proposal is made again;
3. the layer is set, or cleared, exactly as `settings set` and `settings clear` do at the terminal, with the
same judgement and the same history kept, and **who approved it is kept beside the layer**: "approved by
the operator, as telegram identity …, via telegram (user id verified) at …, ask <id>, proposed by <who>".
`settings` says it back, so a layer the operator approved on their phone is told from one typed; a layer
set at the terminal says so, and one set through a verb names the verb.
On Decline nothing is set and the proposal is recorded as declined; on expiry, a cancel, a replacement or
the router's refusal nothing is set and the proposal is recorded as ended. Either way the warrant, or the
end, is recorded in the hand-act log under the verb `warrant`, a person's decision and never a repair, with
who chose, through which channel and with which proofs. **The push afterwards stays a separate act**, as it
is for a layer set at the terminal: the proposal's answer says `push <node>`.
### 4. Fail closed
A proposal is refused at once, in words, naming the terminal's line instead, when no module holding the
operator channel is assigned, when the bus does not yet hold the controller's grant to ask, when the ask could
not be published, or when the ask's words would carry what may not leave the mesh and the change could not be
shown without it. Published, the verb waits a few seconds for the router's word: a refusal (no channel can
carry an approval) is said to the caller, and the proposal is recorded as ended. The router caps open asks at
three per asker, the controller's conditions among them; a proposal that would be the fourth is refused before
it is asked, and the same change proposed twice is refused naming the first.
### 5. A module's own secret is asked for, and typed at the desk
The controller gains the verb **`secret-ask`** (`secret ask <node> <module> <name> [--at <desk>]`): the operator
is asked for the module's own secret in the desk's hidden prompt, on the module's machine or the one `at`
names, exactly as the `give` verb has it since ADR 0259 §10 — `give` now composes the same line. The value is
typed at the desk, sealed to the one call's key before it leaves that machine, opened in the controller, sealed
to the module's machine as `secret accept` seals it, and forgotten: **never in a verb's argument, never on a
channel, never in a log, an answer or an event**. It is refused, before anybody is asked to type, for a secret
the mesh issues itself (the module's bus account, one the mesh may make) and for a module that runs as an
account of its own, whose value is typed at the controller's terminal (ADR 0259 §10, N1-give).
**Bounded, and the prompt names who asked.** Every ask is recorded in the store before the prompt opens, with
who asked (the bus's word on the caller), the secret, the machine, the desk and how it ended: one open ask per
secret, and three an hour, read from that record, so an agent cannot keep a prompt in front of the operator
until they type. The prompt names the module, the secret, the machine and who asked: the caller as the bus
named it, cut to a name's characters by the controller and refused by the launcher in any other shape, never a
word the caller chose. Given, the value is recorded in the hand-act log with who asked, and announced on every
channel as every given secret is.
### 6. What this changes in the earlier records
- **Issue 339's rule** ("set at the controller's terminal only, never through a verb") gains a second way: a
trusted setting is set at the terminal, or on the operator's warrant. The refusal through a verb stands and
names the proposal.
- **ADR 0259 §10, step 7** (the desk prompt opened by `give`): the same prompt is opened by `secret-ask` from
anywhere, bounded, naming who asked; `give` composes its line.
- **ADR 0266** (every `secret` subcommand but `rotate` refused through a verb): `secret ask` is the one other
line a verb runs, carrying no value and answering none.
- **ADR 0217 §1** stands: a proposal that removes a key is refused unless `--replace` says the removal is
meant, and the removal is in the ask's words.
- **ADR 0262** stands: `settings` shows every value and where it came from, and now who set the layer.
## Consequences
- An agent sets a trusted setting with one call and one tap on the operator's phone, and asks for a secret
with one call and the operator's typing at their desk. No line is typed at the control node for either.
- No caller of a verb can set a trusted setting: what it writes is a proposal, and the controller sets the
layer only on a warrant bound to the exact values the operator was shown.
- **What got harder:**
- every trusted setting is a question to the operator, among at most three open asks of the controller's;
- a value with a path or an address is read in part on the phone and whole with `settings proposals`;
- a layer changed between the proposal and the approval refuses the act, and the proposal is made again;
- a secret is asked for at most three times an hour, and a prompt the operator did not start now names who
did start it, so they decline it;
- the store gains a column on the settings layers (migration 0090) and a table of secret asks (migration 0091).
## How it is checked
| Rule | Checked by |
|---|---|
| A proposal changes nothing, and asks at the level approve with the exact change | the controller's test: the ask passes the SDK's check, offers Approve and Decline both at the level approve, each binding an act digest; the explanation carries each key with its value or its shape, the layer's fingerprint and the "was" of a changed key, and passes the content rule; the kept record is the controller's own, and no layer is set (`TestAProposalAsksAtTheLevelApproveWithTheExactChange`, `TestAClearIsProposedAndShowsWhatItRemoves`) |
| What the phone is shown keeps a value's shape and passes the content rule | `TestAValueIsShownInItsShapeAndPassesTheContentRule` |
| A layer the mesh would refuse, or a silent removal, is not asked | `TestAProposalTheMeshWouldRefuseIsNotAsked` |
| The layer is set once, on the operator's approval, with who approved it; a replay changes nothing | `TestTheLayerIsSetOnceOnTheOperatorsApproval`: the warrant heard twice sets once, no verb is called, the hand-act reads `warrant` with the answer and the values' digest, and counts as a person's decision |
| Decline, expiry, cancel, replacement and the router's refusal discard | `TestDeclineExpiryAndRefusalDiscardAProposal`, each outcome, and an approval after the end refused |
| The warrant binds the exact values | `TestAWarrantSetsOnlyTheExactValuesTheOperatorWasShown`: the values or the layer-as-it-stood changed in the record, the action's digest changed, another ask's digest, the level acknowledge, after expiry, nobody chose, another asker's — each sets nothing |
| Set on the real store as at the terminal, judged, the history kept, who approved it said, a stale proposal refused | `TestSetOnAWarrantJudgesTheLayerAndKeepsWhoApprovedIt`: the layer set and `settings show` says who approved it; the same proposal again refused for a changed layer; a removal not meant refused; a line break refused; a clear keeps who cleared it with the history |
| A layer says who set it, at the terminal and through a verb | `TestALayerSaysWhoSetIt` |
| Fail closed | `TestAProposalFailsClosedWhenNothingCanCarryIt`: no router, no grant, a publish that fails, the router's refusal — each refused in words naming the terminal's line, nothing kept or marked unsent |
| Bounded | `TestAProposalIsBounded` (the same change twice; a fourth open ask), `TestOpenProposalsCountTowardTheAsksHeldOpen` |
| A trusted setting through the settings verb is still refused, naming the proposal | `TestATrustedSettingThroughAVerbNamesTheProposal`, and issue 339's tests unchanged |
| The verb composes propose, proposals and proposal; the generic command proposes nothing | `TestTheSettingsVerbComposesProposeAndProposals` |
| The secret's value is never in an argument, a log or an event | `TestASecretGivenAtTheDeskIsSealedAndSaidNowhere` (unchanged), `TestASecretAskIsNeverASecretRead` (a value on the line, a file, recover and export each refused) |
| The prompt names who asked from the bus's word alone, in a name's characters | `TestThePromptNamesWhoAskedFromTheBussWordAlone`; the launcher's `TestThePromptIsWrittenHereFromNamesAndSaysTheControllerAsks` (a caller's name taken, a sentence refused) |
| One open ask per secret, three an hour, and every end recorded | `TestASecretAskIsBoundedAndItsEndRecorded`, and on the store `TestASecretAskIsOneAtATimeAndFewAnHour` |
| The `give` and `secret-ask` verbs run the one desk line, and no other `secret` line passes a verb | `TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk`, `TestOnlyTheGiveLinePassesTheTerminalRuleForSecrets` |
| Live, after rollout | an agent proposes `sources` for mounts on the gaming machine's client; the operator's phone shows "Set mounts on <node>?" with the key, its value in shape, the fingerprint, Approve and Decline; after Approve `settings` shows the layer with "approved by the operator … via telegram", and `hand-acts` shows the warrant. An agent asks for `smb-credentials` there; a prompt on that desk names mounts, the secret and who asked; the operator types; the secret is sealed to that machine's mounts |
## References
- [ADR 0274](0274-an-agent-proposes-a-shared-instruction-section-and-every-node-takes-it-only-on-the-operators-warrant-which-it-proves-itself.md)
— the pattern this record extends to settings and secrets
- [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,
the warrant, and the desk prompt (§10)
- [Issue 339](../04-ISSUES/339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md) — the rule this
record widens
- [ADR 0217](0217-no-change-to-a-machine-takes-effect-unseen.md), [ADR 0262](0262-a-preference-has-a-default-in-its-manifest-and-the-operators-own-value-has-none.md)
— a layer replaced whole, and what `settings` says
- [ADR 0266](0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md)
— the terminal-only commands
- [to-be 44](../03-DESIGN/01-to-be/44-no-change-takes-effect-unseen.md) §1, [to-be 52](../03-DESIGN/01-to-be/52-shares-and-mounts.md)
§2, [to-be 46](../03-DESIGN/01-to-be/46-the-conversation-with-the-operator.md) §10 — the designs amended
- mesh-controller `cmd/mesh-controller/proposals.go`, `desk_secret.go`, `internal/inventory/secret_ask.go`,
migrations 0090 and 0091; mesh-catalog `modules/rofi` (the prompt names who asked)
@@ -0,0 +1,214 @@
---
topic: how we work
status: accepted
date: 2026-10-10
deciders: jochen
reconstructed: false
extends: 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
---
# 278. The issue tracker checks every new issue for a duplicate, through a judgement the agent makes without tools
## Context
On 2026-10-10 the operator asked for "a 'groom backlog' tool for our issue tracker which goes over the
non-resolved issues and checks for duplicates and tries to consolidate issues together, 'cancelling' the other
ones with a reference in the open one", and then: "or even better, that runs when a new issue is submitted".
Agents open issues through `issue-tracker.open` ([ADR 0275](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)).
Nothing tells an agent that the question it is about to open is already open, so the same symptom is opened
again by the next session that meets it. [Research 042](../01-RESEARCH/042-the-mesh-keeps-its-own-issues/01-findings.md)
§3 planned a duplicates pass over the imported issues as candidate pairs "whose titles share most words",
each confirmed before it is applied. Shared words find the pairs a reader would find by searching; they do not
find the same question asked in other words, which is the case that costs time, and they cannot tell a
duplicate from two issues in one area. That judgement reads both reports, which needs a language model.
**What the mesh had for a judgement, measured on 2026-10-10.** No seat or tool runs a model for a module:
`mesh_search` for "headless agent", "llm" and "completion" found nothing. The `claude-code` module holds the
agent on every node and the licence the node is bound to ([ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md));
on the control-node the agent runs as an 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)),
and the operator's account, which the tool runner runs bundles as, may become it. `claude` in print mode with
its built-in tools turned off (`--tools ""`) and in safe mode answers a structured question with only its
answer-shaping tool available: measured on the laptop, the session listed `StructuredOutput` as its only tool,
in 2.2 seconds. Ollama provides `model-access` on the home server only, and the control-node runs no model.
**The bounds that shape the design.** A tool call on the bus is answered within 30 seconds or the asker hears a
timeout (the tool runner's request bound). An event a module consumes is redelivered after 30 seconds without
an acknowledgement, five times at most; after that the controller raises its `max-deliveries` condition and
keeps the event in `DEAD_LETTERS` until a person delivers it again. The tool runner gives a handler two minutes,
but said nothing to the bus while the handler worked, so an event handled for longer than 30 seconds was offered
again while it was handled, and events waiting in the runner's buffer ran out their 30 seconds before they were
handed over: a burst of opens would have ended in `max-deliveries` with nothing failed. Found in this record's
review and opened as issue 387; the tool runner now keeps an event alive while its handler works and holds one
at a time, and this record's code is merged only after that fix. A module that runs only its bundle declares
no health ([ADR 0240](0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md) rule 8), and a
module's health is the only way it raises a condition of its own
([ADR 0256](0256-a-machines-hardware-condition-is-read-by-one-module-on-every-machine-and-a-fault-is-said-through-its-health.md)).
The record was searched first ("duplicate", "groom", "issue opened", "headless", "claude-agent -p", "agent
account"): only research 042's pass and the grooming passes made by hand on 2026-09-29.
**A word.** The glossary has none for this check. This record names it the **duplicate check** and adds it.
## Considered Options
### What starts the check
1. *A schedule, every two hours, over the backlog.* The operator's first idea, and his own better one replaced
it: a duplicate opened at 10:00 stays open and gathers notes until noon.
2. **The tracker's own `opened` event.** The tracker already says every open on the bus
([to-be 53](../03-DESIGN/01-to-be/53-the-issue-tracker.md) §4). Consuming it is the mesh's ordinary way for one
change to start work, it reaches an issue opened by any caller, and the event's redelivery is the retry.
Chosen, with a verb for the backlog that exists today.
3. *A step inside `open`.* Refused: `open` answers within the bus's 30 seconds, and a judgement can take most of
them; an open must never fail because a model was slow.
### Who judges
1. *The tracker calls the vendor's interface itself, with the node's token.* Refused: the token is the agent
module's, handed to it sealed (ADR 0206); a second holder is a second place it can leak, and a subscription's
token is for the agent.
2. *A local model through `model-access`.* Refused for now: none runs on the control-node, and the one on the
home server is weaker at telling "the same question" from "the same area", the one distinction this check
must get right.
3. **The agent module answers one question, as the node's agent with no tools.** `claude-code` gains a tool that
runs the agent in print mode, as the account agents run as, under the licence the node is bound to, with its
built-in tools off and in safe mode (no tool servers, plugins, hooks or instruction files), and answers the
structured answer. Chosen: the module that holds the licence spends it, and the session can do nothing but
answer.
4. *A seat for judgements, with kinds.* Deferred: one module asks today. When a second module asks, or a
second holder (a local model) is worth offering, the tool becomes a seat's verb; until then it is a module's
tool, called on the tracker's own node.
### What a verdict may do
1. *Close whichever of the two is newer, or merge both into a third.* Refused: a third issue loses both
numbers' history, and an agent holding the older number would find it closed.
2. **Close only the newer issue, the one just opened, and only for a clear duplicate.** The older issue stays as
it is and gains the newer one's report as a note, so nothing reported is lost and the number agents already
hold stays open. Chosen.
## Decision
### 1. A new issue is checked when it is opened
`mesh-issues` consumes its own `mesh-issues.opened` event. For each opened issue still at `open`, it judges it
against every **older** issue that is not resolved: at `open`, `diagnosing` or `located`. A resolved or wontfix
issue is never offered, never linked and never changed by the check.
The judgement has two questions, so each fits a tool call's 30 seconds however long the backlog grows; the
check as a whole, waiting behind a judgement of the backlog's pass included, stays under the two minutes the tool
runner allows a handler (issue 387):
1. **A shortlist by titles.** The new issue's title and report against every candidate's number and title; the
answer names at most five numbers that could be the same question or closely related. The titles fill at
most 48 KiB of the question, newest first; when the backlog holds more, the note on the issue says how many
were offered of how many.
2. **A verdict on the reports.** The new issue and each shortlisted issue whole, each report cut at 8 KiB; the
answer is one of `duplicate` (the same question: one answer closes both), `related` (the same area or a
cause in common, two questions) or `none`, with the issue it names, whether it is **certain**, and the reason
in one or two sentences.
An empty shortlist is a verdict of `none` without the second question.
### 2. What each verdict does
| Verdict | Done, through the tracker's own `note`, `link` and `move` |
|---|---|
| `duplicate`, certain, naming an offered issue | the new issue is linked `duplicates` to the older one; the older one gains a note holding the new one's title and report, and who found it and why; the new one gains the verdict as a note; the new one moves to `wontfix` with the reason "duplicate of N" |
| `duplicate` not certain, or `related` | the new issue is linked `relates-to` the older one; both gain a note saying so and why; nothing moves |
| `none` | the new issue gains a note: checked against so many issues, none the same |
The older issue is never moved, so the check never closes both sides. The tracker asks one judgement at a
time, and the backlog's pass (§6) waits while a new issue's check is waiting; one verdict is applied at a time. A verdict naming an issue that was not
offered is a failed judgement (§4), never acted on. The tracker's own code for the verbs does each step, so each
refuses what the verb refuses and says its event; the store is never written around them. Each note and link
records its author as `duplicate-check`, the way an import records `import` (ADR 0275 §5).
**An issue's report is data in the question, and the session has no tools.** A report written to steer the
verdict can at most close the issue that carries it, or link it: the verdict cannot move an older issue, touch a
resolved one or name an issue it was not offered.
### 3. Once per issue, whatever is delivered twice
The verdict note on the new issue is written **first**, and its first line says the verdict in a fixed form
("Duplicate check: duplicate of 412", "related to 412", "none of 85 issues is the same question"). An event
delivered again, or the backlog pass reaching an issue the event already checked, finds that note and does not
judge again: it does only the steps of that verdict not yet done (a link it holds, a note the older issue
holds, a status already `wontfix` are each done). An issue that is no longer at `open` when its event arrives is
not checked.
### 4. A judgement that fails is loud
A judgement fails when the agent module does not answer, answers late or refuses, or answers outside the shape
asked for, or names an issue it was not offered. Then the new issue gains one note saying the duplicate check
could not be made and why — once, not at every delivery — and the handler does not take the event. The bus
offers it again after five seconds; after the fifth time the controller raises `max-deliveries` on the tracker's
consumer, an open condition, and keeps the event for a person to deliver again. A tracker that cannot judge is
therefore never silent and never closes anything.
### 5. The judgement tool
`claude-code` gains `claude_code_judge`: a question in plain words and the JSON schema of the answer, optionally
a model alias. It runs `claude` in print mode, as the node's agent account, in an empty directory, with built-in
tools off, in safe mode and keeping no session, under the licence the node is bound to; and answers the
structured answer, the model and how long it took. It answers within 25 seconds or not at all (the process is
ended), so its caller hears a refusal rather than the bus's timeout. One judgement runs at a time on a node.
It refuses a question larger than 64 KiB, a schema that is not an object, and a node holding no licence, each in
words. The question is never logged or said in an event; the answer goes only to its caller.
### 6. The backlog: `groom`, a dry run first
The tracker's seat gains the verb `groom`, for the issues that were open before this check existed. It runs in
the background, because a judgement per issue takes longer than one call may:
- **Without `confirm`** it starts a dry run: every issue not resolved, oldest first, judged as in §1 against the
older ones not resolved and not already found a duplicate in this pass. It writes no note, link or move. Its
findings — each duplicate and related pair with the reason, and each judgement that failed — are kept as one
**pass** in the module's new state `grooming`, and `groom` with the pass's id answers its progress and
findings.
- **With `confirm` and a pass's id** it applies exactly that pass's duplicate and related findings, not a fresh
judgement, each checked again against the issues as they stand: a finding whose older issue has since been
resolved, or whose newer one has moved on, is skipped and said. It adds no "checked, none" note to the
backlog's issues.
One pass runs at a time. A pass's findings are kept until the next pass replaces them.
### 7. Settings
`duplicate-check` (`on` by default; `off` stops the check on new issues, said in the log) and `judge-model`
(`sonnet` by default; an alias the judgement tool accepts). Both are the module's ordinary settings.
## Consequences
- An issue opened twice is closed as a duplicate within the judgement's time of being opened, with the reason
on both issues and the duplicate's report kept on the older one; agents keep working on one number.
- Every open spends two short judgements of the control-node's licence. At the dozen or so issues a day the
mesh opens, that is noise beside one agent session.
- The duplicate check depends on the agent module and its licence on the tracker's node. When the licence is
gone, every new issue's event ends in `max-deliveries`: loud, and nothing is closed.
- Research 042's word-sharing candidate list for the duplicates pass is replaced by this judgement; to-be 53
slice 3 says so.
## How it is checked
| Rule | Checked by |
|---|---|
| A clear duplicate: linked, its report kept on the older one, the newer moved to wontfix with "duplicate of N" | the tracker's test with the judgement stubbed: the four steps, the verdict note first, each note naming `duplicate-check` |
| Related, or a duplicate not certain: linked `relates-to`, noted on both, nothing moved | the tracker's test, both cases |
| None: one note, nothing else | the tracker's test |
| A resolved or wontfix issue is never offered or changed; the older issue never moves | the tracker's test: a resolved and a wontfix issue in the backlog are absent from both questions; a verdict naming one, or naming an issue not offered, changes nothing and fails |
| Once per issue | the tracker's test: the same event twice judges once; an event after a partial apply finishes only the missing steps |
| A failed judgement is loud | the tracker's test: the handler returns an error, one note is written however often the event comes |
| The dry run changes nothing; confirm applies the pass's findings, re-checked | the tracker's test: issues byte-for-byte unchanged after a dry run; a finding whose older issue was resolved in between is skipped |
| The judgement session can do nothing but answer | the agent module's test of the command line: tools off, safe mode, no session kept, as the agent account, a 25-second bound; a question over 64 KiB and a node without a licence refused |
| Live | an agent opens an issue that repeats one not resolved; within a few minutes `get` shows it `wontfix`, "duplicate of N", linked `duplicates`, and N holds its report as a note; a second, unrelated issue gains only the note that it was checked |
## References
- [ADR 0275](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) — the tracker this record extends
- [Research 042](../01-RESEARCH/042-the-mesh-keeps-its-own-issues/01-findings.md) §3 — the duplicates pass
- [ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md) and
[ADR 0266](0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md) —
whose licence, and which account, a judgement runs under
- [To-be 53](../03-DESIGN/01-to-be/53-the-issue-tracker.md) — the design this record amends
@@ -0,0 +1,106 @@
---
topic: what runs on it
status: accepted
date: 2026-10-10
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md
---
# 280. The resolver restarts itself when it stops answering, braked, and says it as its health
## Context
[ADR 0240](0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md) rule 6 says nothing is
restarted for being unhealthy, and that "a healer that restarts on health is its own decision"; its
options left that to "a record of its own under ADR 0231". This is that record, for one module.
[Issue 348](../04-ISSUES/348-a-resolver-that-stopped-answering-ran-on-unnoticed-and-builds-sent-after-it-were-blamed/00-report.md):
on 2026-10-09 the mesh's resolver on the anchor refused every question on its private address for
twenty minutes (10:57:57 to 11:18:12 UTC) while its unit stayed active and its journal said nothing.
Loopback still answered. The certificate authority failed its health check from 10:59:37 UTC, and builds
sent in that window were blamed for a failure that was the resolver's. Two facts shape the answer:
- **The cause is a known failure of the resolver itself.** dnsmasq 2.93 closes every listener when an
address re-read fails while a bridge is torn down; reproduced in a network namespace, 53 closings in
120 teardowns. The catalogue's fix binds each address once at start (`bind-interfaces`), which removes
the known cause, but a resolver that stops answering for another reason looks the same.
- **A resolver that stops answering can cut its machine off from the rest of the mesh by name.** Then
nothing outside the machine reaches it to repair it: not the gate's rollback
([ADR 0236](0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md)),
and not a healer in the controller
([ADR 0231](0231-a-healer-acts-on-what-observation-raised-and-only-observation-says-it-worked.md)),
which acts by sending to the machine over the bus.
## Considered Options
1. *Keep rule 6 and let the condition reach the operator.* Rejected: the operator is often asleep or away,
and twenty minutes of a dead resolver on the anchor stalls every machine that resolves through it. The
restart is what the operator did by hand on 2026-10-09.
2. *A healer row in the controller's registry (ADR 0231) that restarts the unit.* Rejected for this case:
the repair must reach a machine whose name lookups are failing, and the controller reaches a machine
only through the bus. It remains the right shape for a repair that does not cut its own path.
3. **The module repairs its own resolver, as ADR 0224 §5 lets a provider repair a known failure:
braked, observed by its next look, and said as its health.** Chosen.
## Decision
**The dnsmasq module restarts its own resolver when the resolver stops answering, and only then.** Its
long-running watch asks the machine's own name on every listen address, over UDP and TCP, every 15
seconds. After two failing looks in a row it restarts the resolver's unit. It restarts only a unit that is
running; a stopped or failed unit is the service manager's and the operator's.
**The repair is braked.** At most one restart every two minutes and three within an hour. The budget is
kept in the watch's state on the machine, so a restart of the watch itself does not reset it. When the
budget is spent the watch restarts nothing more until the oldest restart is an hour old, and says why.
**Only the next look says whether it worked** (ADR 0231's rule, applied in the module). A restart helped
only when a look within a minute after it answered. The module's declared health check (`dnsmasq_health`,
ADR 0240 rule 2) reads the watch: a restart that helped leaves the module healthy; one that did not, or a
spent budget, is unhealthy, which the node-engine states and the controller raises as
`module.dnsmasq.<machine>.unhealthy`. The operator hears of it then.
**Every restart is said**, as the module's `restarted` event and in the watch's state, which
`dnsmasq_health` shows.
**The operator's word.** Rule 6 and the catalogue change (mesh-catalog pull request 161) both said this
departure needed the operator's word or a healer record. The operator gave it on 2026-10-10 by approving
the merge, knowing that it makes the resolver restart itself.
**This is one module's exception, not a change to rule 6.** Any other module that wants to restart on
health needs its own record, or a healer row under ADR 0231.
## Consequences
- A resolver that stops answering is back within about 30 seconds plus its start time, instead of after a
person notices it.
- **A module's own repair is not a healer.** It is not counted by the controller's mesh-wide brake, and
it is not listed by the controller's `healers` verb. Its own brake (three an hour) is what bounds it,
and its `restarted` event and health state are where it is seen.
- A restart hides a failure for as long as it helps. The evidence of each one is kept in the event and
the watch's state, so a resolver that is restarted every hour is still visible there. Nothing raises a
condition for repeated restarts that each helped; that is not built, and the operator hears only of a
restart that did not help.
- The rule 6 test in the node-engine still holds: the node-engine restarts nothing. The restart is the
module's, made through the service manager on its own machine.
## References
- [ADR 0240](0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md) rule 6, which this
record makes one exception to.
- [ADR 0231](0231-a-healer-acts-on-what-observation-raised-and-only-observation-says-it-worked.md) — the
rules a repair keeps: braked, and only observation says it worked.
- [ADR 0224](0224-a-provider-that-keeps-failing-a-consumer-is-a-problem-the-controller-reports.md) §5 —
a module repairing a known failure of its own.
- [Issue 348](../04-ISSUES/348-a-resolver-that-stopped-answering-ran-on-unnoticed-and-builds-sent-after-it-were-blamed/00-report.md).
- mesh-catalog pull request 161, branch `fix/the-resolver-answers-or-is-restarted`, `modules/dnsmasq`.
## How it is checked
| What | Checked by |
|---|---|
| two failing looks restart the unit, within the gap and the hourly budget | `TestTheWatchRestartsOnASecondFailingLookWithinItsBudget` (mesh-catalog `modules/dnsmasq`) |
| a restart helped only when a look soon after it answered; health says whether it did | `TestTheWatchRestartsAResolverThatStoppedAnsweringAndHealthSaysWhetherItHelped`, `TestARestartHelpedOnlyWhenTheResolverAnsweredSoonAfterIt` |
| only a running unit is restarted | `TestTheWatchRestartsOnlyAUnitThatRuns` |
| the budget survives the watch's own restart | `TestTheWatchLoopKeepsItsBudgetAcrossItsOwnRestart` |
| live | `dnsmasq_health` on each machine that runs the resolver: its restarts within the hour and why |
+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.
@@ -7,8 +7,9 @@ code:
- mesh-controller examples/postgres-provisioner
- mesh-controller internal/inventory/given.go
- mesh-controller cmd/mesh-controller/given.go
updated: 2026-10-06
updated: 2026-10-10
decisions:
- 02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md
- 02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md
- 02-DECISIONS/0009-modules-and-the-graph.md
- 02-DECISIONS/0085-a-secret-is-a-provision.md
@@ -179,3 +180,10 @@ named and its age; the last is replaced when a person asks `secret rotate`. A pa
delivers is unchanged ([ADR 0092](../../02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md)).
*How it is checked:* the tests ADR 0228 names, and a live `rotate` of a given at-start secret through
the mesh MCP server.
*Amended 2026-10-10 by [ADR 0277](../../02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md).* An outside party's value is also **asked for**: the
controller's `secret-ask` verb, from anywhere on the mesh, opens the desk's hidden prompt on the module's
machine (or the one named), naming the module, the secret and who asked; the operator types the value there,
sealed to the one call and then to the machine, never in an argument, a log or an event. One open ask per
secret, three an hour; refused for a secret the mesh makes itself and for a module running as an account of its
own, whose value stays the terminal's.
@@ -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,9 +2,10 @@
layer: to-be
status: in-progress
code:
- mesh-controller: cmd/mesh-controller/unseen.go, cmd/mesh-controller/push.go (--all, --move, the hold), cmd/mesh-controller/held_back.go (the hold in a named push's cascade), cmd/mesh-controller/plan.go (--diff), cmd/mesh-controller/modules.go (settings show, --replace), cmd/mesh-controller/seatverbs.go and internal/catalogue/verbs.go (the verbs' arguments), internal/inventory/unseen.go, internal/inventory/catalogue.go (the history kept on set and clear), internal/inventory/migrations/0078-what-a-change-replaces.sql
updated: 2026-10-08
- mesh-controller: cmd/mesh-controller/unseen.go, cmd/mesh-controller/push.go (--all, --move, the hold), cmd/mesh-controller/held_back.go (the hold in a named push's cascade), cmd/mesh-controller/plan.go (--diff), cmd/mesh-controller/modules.go (settings show, --replace), cmd/mesh-controller/seatverbs.go and internal/catalogue/verbs.go (the verbs' arguments), internal/inventory/unseen.go, internal/inventory/catalogue.go (the history kept on set and clear), internal/inventory/migrations/0078-what-a-change-replaces.sql, cmd/mesh-controller/proposals.go and internal/inventory/migrations/0090-a-layer-says-who-set-it.sql (a proposal set on a warrant, who set a layer)
updated: 2026-10-10
decisions:
- 02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md
- 02-DECISIONS/0217-no-change-to-a-machine-takes-effect-unseen.md
- 02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md
---
@@ -29,6 +30,21 @@ That stays. Around it:
- **Keeping the previous layer.** Each set and each clear records the layer it replaced and when, so
the previous value is read back with `settings show --history`, not from a backup.
*Amended 2026-10-10 by [ADR 0277](../../02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md).*
- **Who set it is kept with the layer.** A layer says who set it: at the controller's terminal by whom,
through which verb, or approved by the operator on which channel and when (ask id, proposed by whom).
`settings show` says it after the layer; the history copy keeps it.
- **A trusted setting is proposed and set on a warrant.** `settings propose <module> <values | --clear>
[--node] [--replace]` (the verb's `propose`) keeps a proposal in the controller's own asks and asks the
operator on a channel that proves who answers: the module, the machine, every key with its exact new value
(in its shape where a path or an address may not leave the mesh), the "was" of a changed key, the removed
keys, and the layer's fingerprint. Nothing changes until Approve, on which the serving controller sets the
layer as a set at the terminal would — judged the same, the removal meant or refused the same, the history
kept — only when the record's values still digest to what the option bound and the layer is still the one
shown. Decline, expiry or the router's refusal discard it. `settings proposals [<id>]` lists them and shows
one whole. The push afterwards is a separate act.
## 2. A push says what it will change
- **What was sent is kept.** Each send records a summary of the declaration it sent the machine, beside
@@ -2,8 +2,9 @@
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/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md
- 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
- 02-DECISIONS/0264-a-message-a-consumer-gives-up-on-is-kept-until-a-person-delivers-it-again-or-drops-it.md
@@ -72,6 +73,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 +99,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
@@ -235,6 +239,8 @@ signal and asserts its condition. `doctor signals` shows, for every row, the age
| S15 | a hand act with a cause already recorded | hand-act log | each act | the second within 14 days; clears when fewer than two remain within 14 days | `healer-wanted` (names the cause, and the healer that was not enough where one answers it) | warning | — |
| S16 | a walk waiting for its delivery's word is let go | mesh-delivery, through `deliver` (ADR 0239) | each merge whose walk waits | 30 min; urgent after 4 h | `waiting` (names the walk and `plans go`) | warning | — |
| S17 | a send held for the bus's planned step is told to a person (ADR 0271) | controller's plan | each send refused because it would replace the bus outside its step | none: the first refusal; cleared once the bus's machine has been sent the build the mesh holds | `bus-step-waiting`, for the operator (names the machines, what waits, the bus build from and to, since when and the `mesh-controller.bus` call); S3 leaves such a walk out | warning | — |
| S18 | a batch of merges is cut into its walk (ADR 0276) | controller's merge window | each batch | a minute past `merge-window-at-most` while no walk is open: the controller failed to cut it | `batch-not-cut` (names the batch and what it holds) | warning | — |
| S19 | a batch waiting behind an open walk is cut when it ends (ADR 0276) | controller's merge window | each batch closed while a walk is open | that walk's bound, a tier's bound for each of its tiers, from when the batch closed | `batch-behind-walk` (names the walk) | warning | — |
The bus advisories (S9) cost one read-only subscription: the server already publishes them. The
controller translates each into a condition naming the thing in the mesh's words, as the refused reply
@@ -2,8 +2,9 @@
layer: to-be
status: designed
code: []
updated: 2026-10-08
updated: 2026-10-10
decisions:
- 02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.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/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
@@ -380,6 +381,7 @@ the factors are the router's state.
| approve or reject a waiting retirement set (`retire approve` / `retire reject`) | approve |
| confirm a kept binding moves (`pin`, while `binding-kept` names it) | approve |
| end a stuck plan; send a machine its declaration by hand; reset a bus consumer; retry a healer | approve |
| set or clear a trusted setting an agent proposed (`settings propose`, [ADR 0277](../../02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md)) — bound to the exact values shown; the push stays a separate act | approve |
| silence a condition | acknowledge |
| delete a retired consumer's data, one or by age (`cleanup delete`) | destroy |
| change the operator's identities, the away channel, or a factor's enrolment | destroy |
@@ -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,49 @@ 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.
- **The controller's own path.** A merge that moves a module whose walk waits for nobody's word (the controller,
the node-engine, the tool runner, the bus, mesh-delivery) never shares a batch with one that waits for
mesh-delivery's: it has a batch of its own, with its own window, and the catalogue's batch queues behind its
walk like any other. An own-path walk folds no waiting catalogue walk; that walk's merges are batched again
behind it, so no catalogue delivery skips its turn (ADR 0276, decided during the build, 2026-10-10).
- **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 +212,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 +242,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
@@ -2,7 +2,7 @@
layer: to-be
status: in-progress
code: [mesh-host, mesh-controller, mesh-lab, mesh-catalog]
updated: 2026-10-08
updated: 2026-10-10
decisions:
- 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
- 02-DECISIONS/0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md
@@ -10,6 +10,7 @@ decisions:
- 02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md
- 02-DECISIONS/0236-a-build-is-judged-on-its-first-machine-and-put-back-by-something-other-than-itself-and-so-it-rolls-out-unattended.md
- 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md
- 02-DECISIONS/0280-the-resolver-restarts-itself-when-it-stops-answering-braked-and-said-as-its-health.md
- 02-DECISIONS/0256-a-machines-hardware-condition-is-read-by-one-module-on-every-machine-and-a-fault-is-said-through-its-health.md
---
@@ -209,6 +210,11 @@ The runtime restarts what exits, as now. The node-engine never restarts, recreat
being unhealthy. The condition reaches the operator through the conversation (to-be 46), or a healer once a
record gives one that act.
One module repairs itself, by its own record: the dnsmasq module's watch restarts the resolver's unit when
it stops answering, at most once every two minutes and three times an hour, and says through its declared
health whether the restart helped ([ADR 0280](../../02-DECISIONS/0280-the-resolver-restarts-itself-when-it-stops-answering-braked-and-said-as-its-health.md)). The node-engine still restarts
nothing; any other restart on health needs a record of its own.
## 8. A declaration is proved before it is trusted
- **`module check`** refuses a `health` field naming an endpoint the module does not declare under
@@ -483,7 +489,8 @@ controller.
## What is not decided here
- Whether a healer restarts what stays unhealthy (its own record, under ADR 0231).
- Whether a healer restarts what stays unhealthy (its own record, under ADR 0231). The resolver's own
restart is decided, for that module alone, by ADR 0280.
- Health of scheduled work, and of what a module's events do (issue 276).
- A slow starter that needs more than five minutes to be ready — a change to the bound, recorded, when one
exists.
+13 -5
View File
@@ -5,8 +5,9 @@ code:
- mesh-controller internal/catalogue (the seats node-nfs-server and node-mounts)
- mesh-catalog modules/nfs-server
- mesh-catalog modules/mounts
updated: 2026-10-08
updated: 2026-10-10
decisions:
- 02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md
- 02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md
- 02-DECISIONS/0051-shared-data-is-the-operators.md
- 02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md
@@ -103,8 +104,14 @@ The glossary holds the three words.
`name=disk:<uuid>@<mountpoint>`, each optionally `:ro`. `none` (the default) is none.
- **Its secret `smb-credentials`.** One section per SMB source, `[name]` with `username=` and
`password=`, issued outside the mesh and given with `secret accept <node> mounts smb-credentials
--from <file>`. The process writes one credentials file per source, readable by root only. A source
--from <file>` at the controller's terminal — or asked for by an agent with the controller's `secret-ask`
verb, on which a hidden prompt opens at that machine's desk naming the module, the secret and who asked,
and the operator types it there ([ADR 0277](../../02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md)). The process writes one credentials file per source, readable by root only. A source
with no section is said with that remedy and is not mounted.
- **Its settings are trusted settings** (a trusted file asks for them), so a verb may not set them outright:
an agent proposes them (`settings propose mounts … --node <node>`) and the operator approves on their
phone, where the ask shows each source in its shape ("recalbox=‹address›@‹path›") and the layer's
fingerprint; the layer is set on the warrant and pushed after (ADR 0277).
- **What it refuses.** A mount point at, under or above any data the mesh keeps on this machine (the
controller's `data` verb, asked over the bus; until it is read nothing new is set up), a system path,
a mount point where something else is mounted or a folder that is not empty, and a share this machine
@@ -206,9 +213,10 @@ folders, none refused for a reason not understood.
export goes; the mesh's stays. *Undo:* `zfs set sharenfs=<the old value>` by a person, recorded as a
hand-act.
7. **The downloads**, the same way: read-only first, then read-write.
8. **The gaming device**: the operator gives `smb-credentials` with `secret accept`; the source is added
to `sources`; `node-mounts.list` says it `absent` while the device is off, and it mounts on access
when it is on. Then its fstab line is adopted.
8. **The gaming device**: an agent asks for `smb-credentials` with `secret-ask`, and the operator types it at
that machine's desk (or gives it with `secret accept` at the terminal); the agent proposes the source in
`sources`, and the operator approves it on their phone; `node-mounts.list` says it `absent` while the
device is off, and it mounts on access when it is on. Then its fstab line is adopted.
9. **The laptop**, read-only, granted read-only on the home server.
**Root.** Both modules' tools act as root through `sudo -n`, and the `sudo` module grants the operator
+210
View File
@@ -0,0 +1,210 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog modules/mesh-issues, mesh-catalog modules/claude-code, 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
- 02-DECISIONS/0278-the-issue-tracker-checks-every-new-issue-for-a-duplicate-through-a-judgement-the-agent-makes-without-tools.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
├ bucket grooming the backlog pass of the duplicate check (§ 9)
├ setting owners the registered list `located-in` may name
├ verbs open, get, list, move, note, link, import, groom,
│ served by the control-node's tool runner
├ events opened, moved, noted, linked, imported
└ consumes its own `opened`: the duplicate check (§ 9)
claude-code (on the same node)
└ claude_code_judge one question answered by the agent with no tools (§ 9)
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 |
| `groom` | optionally `confirm` and a pass's id | the duplicate check over the backlog (§ 9): without `confirm`, starts a dry run in the background and answers its id; with the id alone, its progress and findings; with `confirm` and the id, applies exactly that pass's findings, each checked again |
| `import` (slice 3) | optionally `dry-run`, a commit | reads every issue folder of hq through the records module and writes each as an issue at its own number; creates what is absent, fills a status the tracker holds empty, never overwrites, never advances the counter; run again, changes nothing and says so |
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** kept its status in that folder's frontmatter until the import of slice 3 ran;
until then the tracker held its number, title and `record`, took its notes and links, and `move` refused it with the
reason: its status lives in its folder. Since the import, every issue is whole in the tracker, folder or not, and a
report in hq carries no status key. 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` checked the folders that carried status until
slice 3; since then it refuses a status key in a report opened after the import, and judges no issue's status.
## 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 |
| The duplicate check acts only on a clear duplicate, never on an older or a resolved issue, once per issue, loudly when it cannot judge | the rows of [ADR 0278](../../02-DECISIONS/0278-the-issue-tracker-checks-every-new-issue-for-a-duplicate-through-a-judgement-the-agent-makes-without-tools.md)'s "How it is checked" |
| 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 (built 2026-10-10; the status keys stay in the reports as a dated snapshot, and one pull request changes playbook 03 and `cycle.py` and adds dated notes to ADR 0019, 0080 and 0275); then the four consolidation passes of research 042 (fixed, duplicates, owners, grouping; the duplicates pass is the duplicate check's `groom`, § 9, judged rather than by shared words), each a list confirmed before it is applied through `move`, `link` and `note`, and an audit of `fixed-by` against the forge | 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.
## 9. The duplicate check
**When an agent opens an issue that repeats one not resolved, the tracker closes the new one as its duplicate
within a few minutes, keeping its report on the older one; an issue that repeats nothing gains a note that it
was checked** ([ADR 0278](../../02-DECISIONS/0278-the-issue-tracker-checks-every-new-issue-for-a-duplicate-through-a-judgement-the-agent-makes-without-tools.md)).
- **Started by the tracker's own `opened` event**, which the module consumes. Only an issue still at `open` is
checked, against every older issue at `open`, `diagnosing` or `located`; resolved and wontfix issues are never
offered or changed.
- **Judged in two questions** to the agent module's `claude_code_judge` on the tracker's node: a shortlist of at
most five by titles, then a verdict on the reports (`duplicate`, `related` or `none`, whether it is certain,
and why). The judging session runs as the node's agent account under its licence, with no tools, in safe
mode, and answers within 25 seconds.
- **A certain duplicate**: the new issue is linked `duplicates`, its title and report become a note on the older
one, its verdict a note on itself, and it moves to `wontfix`, "duplicate of N". **Related, or not certain**: a
`relates-to` link and a note on both. **None**: one note. The older issue never moves. Each step goes through
the tracker's own `note`, `link` and `move`, and is recorded as by `duplicate-check`.
- **Once per issue.** The verdict note is written first, in a fixed first line; an event delivered again does
only the steps not yet done.
- **Loud when it cannot judge.** One note on the new issue, and the event is not taken: after five deliveries
the controller raises `max-deliveries` on the tracker's consumer.
- **The backlog**: `groom` runs the same judgement over every issue not resolved, as a dry run kept in the state
`grooming`, then applies that pass's findings on `confirm`.
- Settings: `duplicate-check` (`on`) and `judge-model` (`sonnet`).
## 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.
@@ -36,6 +36,12 @@ break adds a line of the caller's own to such a file.
## Why it is an issue
> **The mechanism changed — 2026-10-10, by [ADR 0277](../../02-DECISIONS/0277-a-trusted-setting-is-proposed-through-a-verb-and-set-on-the-operators-warrant-and-a-modules-own-secret-is-asked-for-and-typed-at-the-desk.md).** The fix below made every trusted
> setting the controller's terminal's alone. That stands as one of two ways: a trusted setting is now also
> **proposed** through the `settings` verb by anyone the bus admits and **set by the controller only on the
> operator's warrant**, answered on a channel that proves who answered, bound to the exact values shown. The
> refusal through a verb stands, and names the proposal.
These settings were made for the operator. They say where an adopted machine keeps a module's data. But
the mesh has one way to write any setting, the `settings` verb, and whoever may call a verb includes every
agent session on a machine that runs the mesh MCP server. The generic `command` verb reaches the same
@@ -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
@@ -62,3 +62,11 @@ first, the earlier one second; one plan is left open, at the later commit, build
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,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,79 @@
---
status: resolved
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: mesh-catalog#171, mesh-controller#197
replay: R362
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*.
## Resolved
2026-10-10. mesh-delivery's side is mesh-catalog#171 (merged): a walk is tied to every live delivery whose
merge it answers, never to one that has ended, and a waiting walk no live delivery holds is listed by `stalled`
at once. The controller's side is mesh-controller#197: the merge window and the batch, one walk per batch with
one commit per repository, the walk naming every merge it answers, and the bounds of decision 9. The replay
R362 is registered in mesh-lab's replays register (mesh-lab#75), proved to fail on the commit before the
mesh-delivery fix and pass on it. The build raised one rule the record had not settled, decided during the
build and written into ADR 0276: a merge on the controller's own path never shares a batch with a catalogue
merge.
@@ -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.
+10 -5
View File
@@ -22,22 +22,27 @@ both.
```
NNN-short-name/
00-report.md the symptom as observed, with the evidence; status in frontmatter
00-report.md the symptom as observed, with the evidence
01-diagnosis.md the investigation trail, dated, including what was ruled out
```
`NNN` is the number the issue tracker gave. The issue's status, owner (`located-in`), fix (`fixed-by`),
design amendment and replay live in the tracker — `issue-tracker.get NNN` — not here
([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)).
## Frontmatter, on `00-report.md`
```yaml
---
status: open | diagnosing | located | resolved | wontfix
opened: YYYY-MM-DD
located-in: [] # owning repo(s) or module(s), filled by diagnosis
fixed-by: # pull request or commit reference, filled at resolution
amended-design: # design doc path, when the root cause was a design gap
---
```
The report's first heading is `# NNN. The title`, the title the tracker holds. A report opened after the
import of slice 3 carries no `status:`, `located-in:`, `fixed-by:`, `amended-design:`, `replay:` or
`replay-none:`; `cycle.py` refuses them. The folders numbered 001 to 364 keep those keys as the snapshot the
import took, dated in ADR 0275; they are not kept up to date, and nobody edits them.
## Rules
- Anyone may open an issue. No localisation is required to report one.
+7 -5
View File
@@ -32,12 +32,13 @@ alongside `records.py`, `index.py` and `words.py` — run all four before any me
| How do I do X in this repo? | [`00-META/process/`](00-META/process/) — the playbook index is in `00-overview.md` |
| What was decided, and why? | [`02-DECISIONS/README.md`](02-DECISIONS/README.md) (reading order), then the record |
| What is being built / already runs? | [`03-DESIGN/01-to-be/`](03-DESIGN/01-to-be/) / [`03-DESIGN/00-as-is/`](03-DESIGN/00-as-is/) — each doc's frontmatter says its status, decisions and owning code |
| What is broken or was? | [`04-ISSUES/`](04-ISSUES/) — frontmatter carries status/owner/fix |
| What is broken or was? | the issue tracker: `issue-tracker.list` and `issue-tracker.get` through the mesh MCP server; a design or governance issue's narrative is in [`04-ISSUES/`](04-ISSUES/) under its number |
| Which repo owns what code? | [`00-META/repos.md`](00-META/repos.md) |
| Cross-cutting status view? | the `hq-status` skill (generated, never stored) |
Statuses live **only** in frontmatter; follow the pointers there (`decisions:`, `code:`,
`became:`, `fixed-by:`) instead of reconstructing history from memory.
Statuses of research, designs and decisions live **only** in frontmatter; follow the pointers there
(`decisions:`, `code:`, `became:`) instead of reconstructing history from memory. An issue's status,
owner and fix live in the issue tracker ([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)).
## Words
@@ -54,8 +55,9 @@ in running prose; a quotation keeps its words.
runs them on every pull request as its `mesh/repo-check` ([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)).
No new top-level folders without explicit confirmation.
- **Status lives in YAML frontmatter** — on research overviews (`status`, `became`), design
docs (`layer`, `status`, `code`, `updated`), issue reports (`status`, `located-in`,
`fixed-by`, `amended-design`) and decision records (`status`, `date`, `deciders`).
docs (`layer`, `status`, `code`, `updated`) and decision records (`status`, `date`, `deciders`).
An issue's status, owner and fix live in the issue tracker, not here: an issue report carries
`opened` and nothing of its status (playbook [03](00-META/process/03-issues.md)).
Never create a central status file; cross-cutting views are generated from frontmatter.
- **`02-DECISIONS/` records hold their meaning.** Supersede with a new record rather than rewriting
what was decided, the options weighed, or a consequence another record relies on. Fixing a broken