Compare commits

...
Author SHA1 Message Date
mesh-admin 1a2c917abf Merge pull request '57/V1: mesh-vault in Go, holding secrets sealed and answering get sealed to the asker (issue 674)' (#286) from pkg/57-v1-vault-in-go into feat/57-config-and-provisioning 2026-10-14 18:27:49 +00:00
mesh-admin b807b1d211 Merge pull request '57/K2: The list of predefined keys, and a merge check refusing a key removed while a module names it (issue 661)' (#291) from pkg/57-k2-predefined-keys into feat/57-config-and-provisioning 2026-10-14 06:52:29 +00:00
mesh-adminandClaude Opus 5.5 ff3f8ca192 Check that a change to predefined.json removes no key a module still names
mesh/merge-gate head ff3f8ca192a4 merged into trunk dae760224526 — pass: the change touches no module of the mesh's graph
mesh/repo-check head ff3f8ca192a4 merged into trunk dae760224526 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): feat/57-config-and-provisioning'…
mesh/delivery delivered
mesh/delivery-group group pkg/57-k2-predefined-keys delivered: every member is delivered
Part 2 of 2: the check merge-check.sh runs, and its tests with a planted removal (hq ADR 0348 clause 7, issue 661).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 05:51:54 +00:00
mesh-adminandClaude Opus 5.5 9ae9e321a8 Add the list of predefined keys, and refuse removing a key a module still names
Mesh and node config may hold values only for keys on a fixed, reviewed list (hq ADR 0348 clause 7, design 57 package K2, issue 661). predefined.json at the root starts with font-size (a number of points, 6 to 72) and agent-managed-settings (a trusted object). The merge check refuses a change that removes a key a manifest still names, against the trunk's list at the merge base; whether a removed key still holds a value is mesh-config's to refuse (package K3). Part 1 of 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 05:51:15 +00:00
mesh-admin dae7602245 Merge pull request '57/F2b: influxdb serves its org and bucket from its settings (issue 696)' (#290) from pkg/57-f2b-served-keys into feat/57-config-and-provisioning 2026-10-14 05:15:53 +00:00
mesh-adminandClaude Opus 5.5 c0d55ee27d mesh-vault: ADR 0230's rules pinned one at a time, and the words held to the controller's types (review 255)
mesh/merge-gate head c0d55ee27dae merged into trunk b807b1d21102 — pass: [release feat/57-config-and-provisioning, judged by its controller fcdecb436117] t…
mesh/repo-check head c0d55ee27dae merged into trunk b807b1d21102 — pass: [release feat/57-config-and-provisioning, judged by its controller fcdecb436117] i…
mesh/delivery delivered
mesh/delivery-group group pkg/57-v1-vault-in-go delivered: every member is delivered
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:41:01 +00:00
mesh-adminandClaude Opus 5.5 dadf824c3e mesh-vault: tests of the bound, the retry's real path and a hand-out without its record; the README corrected (review 255)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head dadf824c3e33 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head dadf824c3e33 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:39:49 +00:00
mesh-adminandClaude Opus 5.5 8c5d70a186 mesh-vault: the retirement tools take the controller's consumers, confirm, by and via; vault_status; the export's fingerprint (review 255)
mesh/merge-gate head 8c5d70a18677 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 8c5d70a18677 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:39:15 +00:00
mesh-adminandClaude Opus 5.5 958018e696 mesh-vault: the ledger keeps the ledger; retirement moves to retirement.go (review 255)
mesh/merge-gate head 958018e696e4 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 958018e696e4 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:38:27 +00:00
mesh-adminandClaude Opus 5.5 b71e696993 mesh-vault: retirement in the controller's words, held to its types by golden files (review 255)
mesh/merge-gate head b71e696993b3 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head b71e696993b3 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:37:27 +00:00
mesh-adminandClaude Opus 5.5 2dd6510ded influxdb: serve org and bucket from its settings (issue 696)
mesh/merge-gate head 2dd6510ded29 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 2dd6510ded29 merged into trunk 8aa37e8b835d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group pkg/57-f2b-served-keys delivered: every member is delivered
A settings layer will stop replacing what a provider serves (ADR 0348
clause 11). influxdb on ace relies on that today: its layer sets org and
bucket over the served literals, and grafana writes the bucket into its
data source. Serving ${setting:org} and ${setting:bucket}, with today's
literals as defaults, keeps every consumer's binding the same before and
after the controller change. Declaring settings means runtime-config names
the keys it takes; it keeps every key its client reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:36:59 +00:00
mesh-adminandClaude Opus 5.5 6ab38066c9 mesh-vault: bound the record of hand-outs and record only a well-formed ask (review 255)
mesh/merge-gate head 6ab38066c910 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 6ab38066c910 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:36:11 +00:00
mesh-adminandClaude Opus 5.5 c63f7d6e5c mesh-vault: the ledger's tests say ADR 0230's rules, not a withdrawal on the first pass (review 250)
mesh/merge-gate head c63f7d6e5cea merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head c63f7d6e5cea merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:22:30 +00:00
mesh-adminandClaude Opus 5.5 39c38f3745 mesh-vault: tests for the review's fixes, and the README saying what at-rest encryption protects (review 250)
mesh/merge-gate head 39c38f37459a merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 39c38f37459a merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:21:30 +00:00
mesh-adminandClaude Opus 5.5 da889d421d mesh-vault: finish a new operator key after a failure partway, record every hand-out, reach the vault's copies from secret recover (review 250)
mesh/merge-gate head da889d421d71 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/delivery superseded: a newer head of the same pull request
mesh/repo-check head da889d421d71 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:20:25 +00:00
mesh-adminandClaude Opus 5.5 b67fe1ffb1 mesh-vault: sync before rename, record hand-outs, refuse a new key beside secrets, take the operator key as PEM only (review 250)
mesh/delivery-group group pkg/57-v1-vault-in-go delivering: 1 of 2 delivered
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head b67fe1ffb16e merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head b67fe1ffb16e merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:19:36 +00:00
mesh-adminandClaude Opus 5.5 2000c02559 mesh-vault: retire a holder by ADR 0230, never delete it on a pass; refuse a contributions file that says nothing (review 250)
mesh/delivery superseded: a newer head of the same pull request
mesh/delivery-group group pkg/57-v1-vault-in-go checking: 1 of 2 member(s) ready
mesh/merge-gate head 2000c02559d8 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 2000c02559d8 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:18:51 +00:00
mesh-adminandClaude Opus 5.5 84d8198f15 mesh-vault: ADR 0349's vault tests (issue 674)
mesh/merge-gate head 84d8198f1540 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 84d8198f1540 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery-group group pkg/57-v1-vault-in-go rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:57:54 +00:00
mesh-adminandClaude Opus 5.5 329fc420c9 mesh-vault: the ledger's and the tools' tests, and the README settling the key formats (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:56:44 +00:00
mesh-adminandClaude Opus 5.5 da04ad62ae mesh-vault: the tools the TypeScript vault offered, in Go (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:55:34 +00:00
mesh-adminandClaude Opus 5.5 11fc3258b4 mesh-vault: get, secret-changed and the custody ledger (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:54:22 +00:00
mesh-adminandClaude Opus 5.5 44d6a18e43 mesh-vault: its store, its keys and its rules (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:53:38 +00:00
mesh-adminandClaude Opus 5.5 71c611b768 Rewrite mesh-vault in Go, holding secrets sealed and handing them out on request (issue 674)
The vault was read-only TypeScript and held no secret the mesh uses (hq ADR
0349 §7, §11; ADR 0113 §1, §2, §22). It now keeps each secret in its own store,
sealed to its own key and to the operator key, answers the seat's verb get
sealed to the asker's key and only to an allowed asker (nobody yet: V4 and P3
bring the rule), raises secret-changed without a value, and takes no private
key. The custody ledger and its three tools carry over unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:52:59 +00:00
mesh-admin 8aa37e8b83 Open the integration branch of release 57 (config and provisioning)
So the packages of hq design 57 meet here, reviewed one by one, and reach main as one delivery group.
2026-10-13 23:39:24 +00:00
mesh-admin ebf7f3773d Merge pull request 'build-agent: declare busy, so a replaced agent finishes the check it runs (issue 594, hq ADR 0335)' (#283) from feat/335-the-build-agent-declares-busy into main 2026-10-13 22:39:50 +00:00
jschoubben c876ad4a83 Merge remote-tracking branch 'origin/main' into feat/335-the-build-agent-declares-busy
mesh/merge-gate head c876ad4a8303 merged into trunk 23a2e1e20467 — pass: builds build-agent → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head c876ad4a8303 merged into trunk 23a2e1e20467 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
# Conflicts:
#	modules/build-agent/README.md
2026-10-14 00:11:50 +02:00
jschoubben 12a5e7be73 build-agent: name mesh-builder by its path in busy, as its health does (review of #260)
mesh/merge-gate head 12a5e7be7321 merged into trunk 23a2e1e20467 — fail: builds build-agent → ace, g14, novox, shanks; no bus step; head 12a5e7be7321 does …
mesh/repo-check head 12a5e7be7321 merged into trunk 23a2e1e20467 — fail: head 12a5e7be7321 does not merge cleanly into trunk 23a2e1e20467: in conflict modu…
mesh/delivery superseded: a newer head of the same pull request
2026-10-14 00:07:42 +02:00
mesh-admin 23a2e1e204 Merge pull request 'mesh-delivery: a closed pull request's check builds go, and a walk says what each build did (ADR 0350, issue 649)' (#282) from feat/350-one-build-per-change into main 2026-10-13 21:30:30 +00:00
jschoubben 66d22dd53e Let a closed pull request's check builds go, and show what each walk build did
mesh/merge-gate head 66d22dd53eff merged into trunk 7ed119995f69 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 66d22dd53eff merged into trunk 7ed119995f69 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group feat/350-one-build-per-change delivered: every member is delivered
A check's builds for its merge have no taker once the pull request closes
unmerged, and the delivery should say whether a module was reused from the
check or built, and why (novox/hq ADR 0350 §4, §5).
2026-10-13 23:04:41 +02:00
mesh-admin 7ed119995f Merge pull request 'gitea serves the registry's public root; the lab trusts a tarball only there or at the forge's own origin (issue 624, ADR 0346)' (#280) from fix/624-the-forge-serves-its-public-root into main 2026-10-13 19:52:05 +00:00
mesh-admin faebc3569a Merge pull request 'An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639)' (#278) from fix/383-an-ask-reads-at-a-glance into main 2026-10-13 19:02:21 +00:00
jochen 1faee6e279 lab: a forge setting that is not http(s) is never a trusted origin (hq issue 624, review 222)
mesh/merge-gate head 1faee6e27976 merged into trunk faebc3569acf — pass: builds gitea, lab → novox, shanks; no bus step; every machine composes with the ch…
mesh/repo-check head 1faee6e27976 merged into trunk faebc3569acf — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-13 20:33:13 +02:00
jochen 6f7d06726c gitea serves the registry's public root, and the lab trusts a tarball only at it or the forge's own origin (hq ADR 0346, issue 624)
The forge writes every tarball at its public name and the lab reaches it at
a private address, so npm 12 refused every locked @novox package. The public
name now comes from the mesh's own composition of the forge's route, through
the lab's binding, and the lab compares scheme, host and port.
2026-10-13 20:33:02 +02:00
jochen f09daabec3 A withheld change line in the note keeps its key's name; SDK at 5933eb7 (the review of mesh-controller #304)
mesh/merge-gate head f09daabec3a8 merged into trunk 9c166398f204 — pass: builds claude-code, desk-channel, mesh-knowledge, messenger, telegram → ace, g14, …
mesh/repo-check head f09daabec3a8 merged into trunk 9c166398f204 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/383-an-ask-reads-at-a-glance delivered: every member is delivered
The router's fallback said a refused note line as "(a line carrying … is withheld here)", so the operator
could not tell which setting held a value they were not shown. A change's line now keeps its key; any other
line, or one whose key is refused too, is said withheld whole. The SDK pin moves to mesh-sdk 5933eb7, where
no two asks digest the same.
2026-10-13 20:32:50 +02:00
jochen bb5a342863 Every value an approval sets is in the note, whole where the sender is proven; the note is kept whole (issue 383)
mesh/merge-gate head bb5a342863ee merged into trunk 9c166398f204 — pass: builds claude-code, desk-channel, mesh-knowledge, messenger, telegram → ace, g14, …
mesh/repo-check head bb5a342863ee merged into trunk 9c166398f204 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery-group group fix/383-an-ask-reads-at-a-glance checking: 1 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
The review of mesh-controller #304: a proposal of more than three keys showed only the names of the rest,
and its Details could carry no value, so Approve bound values the operator never saw. The router now
shows an ask's WholeDetails in the note in place of its Details on a kind that proves who answers, held
to the secret rule alone, and refuses an ask whose whole details carry a secret's shape. Telegram keeps
the note whole when a message is too long, cutting the body first and saying so, and escapes the note
as every other part. SDK at mesh-sdk f474544.
2026-10-13 17:16:50 +02:00
jochen eadf301f9f An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639)
The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold,
no summary. A settings proposal was the same wall of text, its values masked so the approval was blind.

- messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each
  answer does, the expiry, where else it is answered and the asker's Details travel as the message's note
  (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram ·
  <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind
  that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone
  (from the earlier work on issue 383, #178).
- telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in
  a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message
  shown before is still compared by the digest it was kept under.
- desk-channel: the note follows the body.
- mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three
  points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is
  composed again as it was asked (form), so its warrant still holds.
- claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the
  module's), its text in the Details; a question's context is at most 300 characters and its recommendation
  one point. Older proposals and questions keep their form.

The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and
asks.Glance.
2026-10-13 17:16:50 +02:00
mesh-admin 9c166398f2 Merge pull request 'Revert #277: no showmissed lock in betterbird's policy (issue 640)' (#281) from fix/640-revert-the-showmissed-lock into main 2026-10-13 15:06:09 +00:00
mesh-admin e209d46faf Merge pull request 'claude-code: the memory watch only notes a change, so it starts on a node with hundreds of files (issue 367)' (#279) from fix/367-memory-watch-takes-fast into main 2026-10-13 14:58:35 +00:00
jochen f1f866050e Revert the showmissed lock of #277: the operator wants no special handling in betterbird's config (hq issue 640)
mesh/merge-gate head f1f866050eee merged into trunk e209d46faf3d — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head f1f866050eee merged into trunk e209d46faf3d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The fix is the approved settings layer (proposal s09bf8c99da7ac8b9:
calendar.alarms.showmissed false), pushed to g14 and shanks.
2026-10-13 16:54:51 +02:00
mesh-admin 4d15a155e6 Merge pull request 'betterbird: a passed meeting's reminder does not come back at every Owl reload (issue 640)' (#277) from fix/640-a-dismissed-reminder-stays-dismissed into main 2026-10-13 14:47:58 +00:00
jschoubben e46eeb8298 claude-code: the memory watch only notes a change, so it starts on a node with hundreds of files (issue 367)
mesh/merge-gate head e46eeb829853 merged into trunk 4d15a155e634 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head e46eeb829853 merged into trunk 4d15a155e634 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The runtime hands a watch its current values one at a time and gives them ten
seconds. The handler read the state, waited for a running look and wrote files
for each value, so with about 450 memory values the watch never started on g14
or shanks, and a file written on g14 never reached shanks. The handler now
notes each key's newest revision and returns; a worker applies what was noted
at the state's current version.
2026-10-13 16:40:41 +02:00
mesh-admin 5f491e0c09 Merge pull request 'lab: name the package registry by the address it names itself by, so npm 12 installs locked @novox packages (issue 624)' (#275) from fix/624-lab-names-the-registry-as-it-names-itself into main 2026-10-13 14:35:04 +00:00
jochen 73404a3c3f betterbird: lock the missed-reminder pass off, so a passed meeting's reminder does not come back (hq issue 640)
mesh/merge-gate head 73404a3c3f21 merged into trunk 5f491e0c097d — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head 73404a3c3f21 merged into trunk 5f491e0c097d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
With calendar.alarms.showmissed on, Betterbird's alarm service fires every
reminder of the past month that carries no later acknowledgement at each
load of a calendar. Owl never sends a dismissal to Exchange and keeps it only
in Betterbird's local cache, so on g14 September's Exchange meetings came
back about every ten minutes, Dismiss or not. The policy now locks the pass
off whatever the setting or profile says, and betterbird_check says when the
running Betterbird still has it on beside an Owl calendar.
2026-10-13 16:32:17 +02:00
mesh-admin 305c2ba247 Merge pull request 'claude-code: a session's memory follows the operator to every node (issue 367, ADR 0343)' (#276) from fix/367-memory-follows-the-operator into main 2026-10-13 14:26:59 +00:00
jochen 8ad121d5c3 lab: take the registry's address only from the forge's own host, built from the parsed URL (hq issue 624, review of #275)
mesh/merge-gate head 8ad121d5c369 merged into trunk 305c2ba24733 — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head 8ad121d5c369 merged into trunk 305c2ba24733 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
What the registry answers may come over plain HTTP, so it must not choose
where packages come from, nor reach the run's npm configuration as a raw
string.
2026-10-13 16:26:48 +02:00
jschoubben 305601d25e claude-code: memory carrying never leaves a memory folder, never takes the unreadable for gone, and screens every write (issue 367, review of #276)
mesh/merge-gate head 305601d25ef4 merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 305601d25ef4 merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A value in the state could name a project such as ../../.. and be written outside
the memory folder; a file or folder that could not be read was carried as a
removal; text kept over a removal or merged in a conflict reached the state
unscreened; and a conflict whose file could not be written was merged into the
state again on every look. Names are judged before any file is touched, what
cannot be read is kept and listed, every state write is screened, a conflict is
written to the file before the state and warns once when it cannot be, a file
changed between reading and writing is not written over, non-UTF-8 text is not
carried, and a node that was away takes a removal it holds no change against
(ADR 0343 §9, as clarified).
2026-10-13 16:20:02 +02:00
jschoubben 910bc58fe0 claude-code: keep two memory versions too large for one file in two, and say a text that cannot fit one value (issue 367)
mesh/merge-gate head 910bc58fe01d merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 910bc58fe01d merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
A merged file could exceed the state's 256 KiB value; the newer now stays in the
file and the older is written beside it, each carried. A node that was away takes
the newest version when its own file holds no uncarried change (tested).
2026-10-13 16:03:37 +02:00
jschoubben 0096beeff9 claude-code: say a memory conflict in the operator's plain words, and skip a look the state does not answer (issue 367)
mesh/merge-gate head 0096beeff999 merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 0096beeff999 merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
The warning now opens with what the operator is needed for and names no path,
agent or session (ADR 0253's plain rule); clause references follow ADR 0343 as
reviewed.
2026-10-13 16:01:16 +02:00
jschoubben 6aa30eae66 claude-code: carry each project's memory folder between nodes (issue 367, ADR 0343)
mesh/merge-gate head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
A session started on another node began without what the sessions elsewhere had
learned, because Claude Code keeps project memory on one node. The module's own
process now carries the memory folders through its kept memory state: a change
is written elsewhere only over the version it replaced, a file changed on two
nodes keeps both (newer first) with a warning, and every replaced or removed
version is kept. No tool writes memory.
2026-10-13 15:57:44 +02:00
jochen bb60de6d61 lab: name the package registry by the address it names itself by, so npm 12 installs locked @novox packages (hq issue 624)
mesh/merge-gate head bb60de6d616a merged into trunk a84d0094a0a3 — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head bb60de6d616a merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
npm 12 installs a locked tarball only from its configured registry; the forge
writes every tarball URL, and so every lock file, at its public address, and
the lab named the registry by its forge setting's address. Every run stopped
at mesh-tools' install with EALLOWREMOTE.
2026-10-13 15:25:41 +02:00
mesh-admin a84d0094a0 Merge pull request 'lab: read the bound forge credential through the tools bundle's env, and check in a fresh directory each time (issue 624)' (#274) from fix/624-lab-check-in-a-fresh-directory into main 2026-10-13 12:58:15 +00:00
jochen dd39d4ace7 lab: name the bound credential in the tools bundle's env, and skip the locked-tree test as root (hq issue 624, reviews 198, 199)
mesh/merge-gate head dd39d4ace74b merged into trunk 0d723484a84a — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head dd39d4ace74b merged into trunk 0d723484a84a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The lab's tools read their bound npm-package-registry credential, root's at 0600 on the machine. The
controller gives the runtime's account exactly the files a tools bundle names in its env (ADR 0192), as
gitea and umami are given theirs, so the bundle now names the credential and its binding there, and no
other credential changes hands.

The merge check runs as root, where no directory mode stops a removal: the test that makes an
unremovable tree now skips there before it makes anything, and a second test clears leftovers whoever
runs it.
2026-10-13 14:51:18 +02:00
jochen 5bd40fa406 lab_check: work in a fresh directory each time, and name what an earlier check left (hq issue 624)
mesh/merge-gate head 5bd40fa4061d merged into trunk 0d723484a84a — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head 5bd40fa4061d merged into trunk 0d723484a84a — fail: merge-check.sh (go) passed; its merge-check-typescript.sh failed: TypeScript modul…
mesh/delivery superseded: a newer head of the same pull request
lab_check cloned into one fixed path, <work>/check, after an rm -rf whose result it ignored; a tree an
earlier check left that the lab's account could not remove made every check fail on "destination path
already exists". Each check now works in a fresh check-* directory it removes when done, and clears
what earlier checks left, naming any path it could not remove.
2026-10-13 14:40:06 +02:00
jschoubben 1906081937 build-agent: declare busy, so a replaced agent finishes the check it runs
mesh/delivery-group group feat/335-a-busy-container-is-replaced-when-idle rejected: its order contradicts itself: novox/mesh-catalog@190608193764, novox/mesh-c…
mesh/merge-gate out of date: judged against trunk 8f80cd471634, the trunk is now 39e7a06e6293; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 8f80cd471634, the trunk is now 39e7a06e6293; judged again on a recheck or when it is merged
mesh/delivery stopped: the pull request closed unmerged
Every merge into mesh-controller replaced the agents and the queue started
their checks again elsewhere (issue 594). Its server now declares busy as
mesh-builder busy (novox/hq ADR 0335 §5).
2026-10-13 00:53:40 +02:00
97 changed files with 8290 additions and 700 deletions
+6
View File
@@ -0,0 +1,6 @@
# Release 57: config and provisioning
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/predefined
go 1.22
+153
View File
@@ -0,0 +1,153 @@
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
// 0348 clause 7, design 57 package K2, issue 661).
//
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
// it, naming each module that names the key.
//
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
// itself, removing a key nobody names is let through here, and said.
//
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
// module check; this check reads only its keys.
//
// go run . [-base <the trunk's predefined.json>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// File is the list's file at the root of the catalogue.
const File = "predefined.json"
// Keys is the keys of a list, read as a JSON object; an absent file has none.
func Keys(path string) (map[string]bool, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return map[string]bool{}, nil
}
if err != nil {
return nil, err
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
}
out := map[string]bool{}
for k := range keys {
out[k] = true
}
return out, nil
}
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
func Named(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
named := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Config map[string]json.RawMessage `json:"config"`
Settings map[string]json.RawMessage `json:"settings"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
for _, d := range declared {
var value struct {
Predefined string `json:"predefined"`
}
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
named[value.Predefined] = append(named[value.Predefined], m.Module)
}
}
}
}
return named, nil
}
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
// key nobody names is answered with none.
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
out := map[string][]string{}
for k := range base {
if !tree[k] {
out[k] = named[k]
}
}
return out
}
func main() {
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
flag.Parse()
if flag.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
os.Exit(2)
}
root := flag.Arg(0)
tree, err := Keys(filepath.Join(root, File))
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if *basePath == "" {
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
return
}
base, err := Keys(*basePath)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
named, err := Named(root)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
removed := Removed(base, tree, named)
keys := make([]string, 0, len(removed))
for k := range removed {
keys = append(keys, k)
}
sort.Strings(keys)
failed := false
for _, k := range keys {
if modules := removed[k]; len(modules) > 0 {
failed = true
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
"clause 7)\n", File, k, strings.Join(modules, ", "))
} else {
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
"value for it is mesh-config's to refuse, not this check's\n", File, k)
}
}
if failed {
os.Exit(1)
}
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
// agent-managed-settings.
func aCatalogue(t *testing.T, list string) (root, base string) {
t.Helper()
root = t.TempDir()
write := func(path, text string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
if list != "" {
write(filepath.Join(root, File), list)
}
base = filepath.Join(t.TempDir(), "base.json")
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
return root, base
}
func run(t *testing.T, args ...string) (string, error) {
t.Helper()
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
return string(out), err
}
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
// The planted removal: font-size goes while dunst still falls back to it.
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
t.Fatalf("a removal of a named key passed:\n%s", out)
}
}
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
out, err := run(t, "-base", base, root)
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
!strings.Contains(out, "mesh-config's to refuse") {
t.Fatalf("a removal nobody names: %v\n%s", err, out)
}
}
func TestAListUnchangedOrGrownPasses(t *testing.T) {
for _, list := range []string{
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
} {
root, base := aCatalogue(t, list)
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
t.Fatalf("%s: %v\n%s", list, err, out)
}
}
// No trunk list given: nothing was removed.
root, _ := aCatalogue(t, `{}`)
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
t.Fatalf("without a base: %v\n%s", err, out)
}
}
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
root, base := aCatalogue(t, "")
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
t.Fatalf("the list removed whole passed:\n%s", out)
}
}
func TestNamedReadsBothForms(t *testing.T) {
root, _ := aCatalogue(t, "")
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
t.Fatal(err)
}
named, err := Named(root)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
t.Fatalf("font-size named by %s", got)
}
}
+16
View File
@@ -33,6 +33,10 @@
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
# the nested ones the controller cannot protect alone yet, and only shrinks.
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
# every manifest against it are judged by the controller's module check in step 1.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
@@ -51,6 +55,18 @@ else
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
fi
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
# list at the merge base is what it is judged against; a trunk without the file had none.
predefined_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
predefined_base=$(mktemp)
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
echo '{}' >"$predefined_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
fi
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
base_lists=""
+5
View File
@@ -13,6 +13,11 @@ that repository's main (`build.artifacts[].context`); this module ships the pack
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
same thing; retire that once this is assigned where it was.
A send that replaces the agent's container does not throw away the build it runs: the container declares
`busy` as `/usr/local/bin/mesh-builder busy` (novox/hq ADR 0335). The node-engine asks it before replacing it; from that
ask the agent takes no new build, the node-engine leaves it running while it says a build is in hand, and
replaces it once it says idle, or after 15 minutes at the latest.
## The package registry (novox/hq ADR 0336)
The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement.
+3
View File
@@ -85,6 +85,9 @@
"timeout": "5s",
"looks": 3,
"grace": "60s"
},
"busy": {
"command": "/usr/local/bin/mesh-builder busy"
}
}
],
+38 -5
View File
@@ -11,8 +11,9 @@ Two directories, declared, so the mesh refuses a second module owning either:
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
account of its own), that account's, `0700`. The module owns the directory —
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
in it (history, projects, local settings, a person's own rules and skills) is the person's
and is never read or written (hq ADR 0182) — but for each project's memory folder, which the module
carries between nodes (hq ADR 0343, below). Unassigned, the module leaves the directory: the host
removes a directory only when it is empty.
And, for where the agent runs as an account of its own (hq ADR 0266):
@@ -69,8 +70,37 @@ must see, a node that joins later included — kept, so it carries no secret eit
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
## A session's memory follows the operator (hq ADR 0343)
A session started on another node starts with what the sessions on every other node learned (hq issue 367). The
module's long-running process carries each project's memory folder, `~/.claude/projects/<project>/memory/`, and
nothing else of the home. A project is one entry of the projects directory and a path is relative, no part empty,
`.` or `..`: a value in the state naming anything else is never taken, and nothing is touched for it.
- **Picked up** every ten seconds: a file whose text differs from the version last carried goes into the `memory`
state. Not carried, and listed with why: a symbolic link, a file over 128 KiB or too large for one value once
encoded, and a file holding a key block, a
signed token, a known token prefix or a password in an address.
- **Written** on every other node of the same home kind, only where its file is still the version the change
replaced, or the version it last carried (a node that was away), or there is none; staged in the folder and renamed in, never through a symbolic link. The version
replaced is kept first in `memory-copies/` in the module's state directory, 30 days.
- **A file changed on two nodes** becomes one file holding both, the newer first, then a line naming each version's
node and time, then the older (two versions too large for one file: the newer in it, the older beside it as
`<name>.older-from-<node>`); what was written is carried, so every node ends on it, and the operator is warned through
the operator channel's `notify` (a warning, cleared when the file next changes without a conflict).
- **A removal** of a carried file, from a folder that still exists, is a marker in the state for 30 days: a node still
holding the removed version moves it into the copies; a node that changed it keeps it and carries it again. A
missing folder, a key the state lost and a deleted key remove nothing, and neither does a file or folder that
cannot be read on a look: it is kept everywhere and listed as not carried.
- **The two home kinds never mix**: the operator's own homes carry among themselves, an agent account's among
themselves (hq ADR 0266), so a confined agent's notes never reach a session run as the operator.
- **No tool writes memory** (hq issue 340): `claude_code_memory` only lists what is carried — fingerprints, never the
text — the files not carried, the open conflicts and the copies kept. A value put straight into the state by a
holder of a tool runner's credential is not yet told apart (hq issue 638).
## The mesh's tools first (hq ADR 0245)
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
@@ -149,9 +179,12 @@ said in the render's answer. What was registered before is still rendered and ma
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
proves who answered (Telegram) carries either. The phone shows the whole text when it is short and every line may
leave the mesh, else its first lines and a fingerprint; the whole text is read at the controller's terminal with
`mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. Decline, or no answer within a day, discards it.
proves who answered (Telegram) carries either. The ask reads at a glance (hq issue 639): its explanation is one
line saying what the proposal does — the proposer's `summary`, at most 300 characters, or the module's own words —
and its Details, which the phone shows collapsed after the answers, carry the text (whole when it is short and every
line may leave the mesh, else its first lines), where it came from and its fingerprint; the whole text is read at the
controller's terminal with `mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. A proposal made before
that change is asked, and its warrant held, in the form it was made in (`form`). Decline, or no answer within a day, discards it.
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
@@ -152,6 +152,36 @@ func (s stateOf) Create(key string, value any) error {
return err
}
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
// memoryStateOf adapts the SDK's state to the memory carrier: every write at the revision read (ADR 0343).
type memoryStateOf struct{ s stdio.KeptState }
func (s memoryStateOf) Get(key string) (json.RawMessage, uint64, bool, error) {
e, err := s.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (s memoryStateOf) Create(key string, value any) error {
_, err := s.s.Create(key, value)
return err
}
func (s memoryStateOf) Update(key string, value any, revision uint64) error {
_, err := s.s.Update(key, value, revision)
return err
}
func (s memoryStateOf) Keys() ([]string, error) { return s.s.Keys() }
func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it.
var memoryCarrier *MemoryCarrier
// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253).
func notifyOperator(args map[string]any) error {
_, err := ask("seat:operator-channel.notify", args)
return err
}
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
e, err := s.s.Get(key)
if err != nil || e == nil {
@@ -354,6 +384,9 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
out = append(out, stdio.Tool{Name: "claude_code_registrations",
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
out = append(out, stdio.Tool{Name: "claude_code_memory",
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
out = append(out, proposalTools(p)...)
judge := NewJudge(p)
out = append(out, judgeTool(judge), judgedTool(judge))
@@ -430,6 +463,12 @@ func main() {
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
Now: time.Now, Write: writeManaged}
go proposing.watch(p)
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
return handle(c.Key, c.Op, c.Value, c.Revision)
})
})
go run(p, view, configView)
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
say("%v", err)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,968 @@
package main
// A session's memory follows the operator (novox/hq ADR 0343, issue 367). Every test runs against temporary homes
// and a state in memory with the bus's watch semantics: never the live memory folders.
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
)
// memState is the `memory` state in memory: revisions, create and compare-and-set, and every change handed to every
// watcher, the writer included, as the bus does.
type memState struct {
mu sync.Mutex
values map[string]json.RawMessage
revs map[string]uint64
rev uint64
pending []change
watchers []*MemoryCarrier
}
type change struct {
key, op string
value json.RawMessage
rev uint64
}
func newMemState() *memState {
return &memState{values: map[string]json.RawMessage{}, revs: map[string]uint64{}}
}
var errChanged = errors.New("wrong last revision")
func (s *memState) Get(key string) (json.RawMessage, uint64, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.values[key]
return v, s.revs[key], ok, nil
}
func (s *memState) write(key string, value any) {
raw, _ := json.Marshal(value)
s.rev++
s.values[key], s.revs[key] = raw, s.rev
s.pending = append(s.pending, change{key, "put", raw, s.rev})
}
func (s *memState) Create(key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.values[key]; ok {
return errChanged
}
s.write(key, value)
return nil
}
func (s *memState) Update(key string, value any, revision uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.revs[key] != revision {
return errChanged
}
s.write(key, value)
return nil
}
func (s *memState) Keys() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
var keys []string
for k := range s.values {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}
func (s *memState) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.values, key)
delete(s.revs, key)
s.rev++
s.pending = append(s.pending, change{key, "delete", nil, s.rev})
return nil
}
// pump hands every change so far to every watcher, until none is left.
func (s *memState) pump(t *testing.T) {
t.Helper()
for i := 0; i < 50; i++ {
s.mu.Lock()
todo := s.pending
s.pending = nil
s.mu.Unlock()
if len(todo) == 0 {
return
}
for _, c := range todo {
for _, w := range s.watchers {
if err := w.Take(c.key, c.op, c.value, c.rev); err != nil {
t.Logf("%s taking %s: %v", w.Node, c.key, err)
}
}
}
for _, w := range s.watchers {
if err := w.Drain(); err != nil && !errors.Is(err, errChanged) {
t.Logf("%s applying: %v", w.Node, err)
}
}
}
t.Fatal("the state never settled")
}
// clock is a clock a test moves.
type clock struct{ now time.Time }
func (c *clock) Now() time.Time { return c.now }
// memNode is one node's carrier over its own temporary home and state directory.
type memNode struct {
*MemoryCarrier
notices []map[string]any
}
func newMemNode(t *testing.T, root, name, kind string, state *memState, c *clock) *memNode {
t.Helper()
n := &memNode{}
home := filepath.Join(root, name, "home")
stateDir := filepath.Join(root, name, "state")
for _, d := range []string{filepath.Join(home, ".claude", "projects"), stateDir} {
if err := os.MkdirAll(d, 0o700); err != nil {
t.Fatal(err)
}
}
n.MemoryCarrier = &MemoryCarrier{Home: home, Node: name, State: stateDir, Kind: kind, Store: state, Now: c.Now,
wake: make(chan struct{}, 1),
Notify: func(a map[string]any) error { n.notices = append(n.notices, a); return nil }}
state.watchers = append(state.watchers, n.MemoryCarrier)
return n
}
func (n *memNode) path(project, file string) string {
return filepath.Join(n.Home, ".claude", "projects", project, "memory", filepath.FromSlash(file))
}
func (n *memNode) write(t *testing.T, project, file, text string, at time.Time) {
t.Helper()
p := n.path(project, file)
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
_ = os.Chtimes(p, at, at)
}
func (n *memNode) read(t *testing.T, project, file string) (string, bool) {
t.Helper()
raw, err := os.ReadFile(n.path(project, file))
if os.IsNotExist(err) {
return "", false
}
if err != nil {
t.Fatal(err)
}
return string(raw), true
}
func (n *memNode) look(t *testing.T) {
t.Helper()
if err := n.Look(); err != nil && !errors.Is(err, errChanged) {
t.Fatalf("%s's look: %v", n.Node, err)
}
}
// copies is every text kept in the node's copies.
func (n *memNode) copies(t *testing.T) []string {
t.Helper()
var out []string
_ = filepath.WalkDir(n.MemoryCarrier.copies(), func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
raw, _ := os.ReadFile(p)
out = append(out, string(raw))
}
return nil
})
return out
}
func holdsText(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
const project = "-home-op-projects-novox-hq"
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
root := t.TempDir()
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
}
// carried writes a file on g14 and carries it to shanks.
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
t.Helper()
a.write(t, project, file, text, c.now)
a.look(t)
state.pump(t)
b.look(t)
state.pump(t)
if got, _ := b.read(t, project, file); got != text {
t.Fatalf("not carried to %s: %q", b.Node, got)
}
}
// §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once.
func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
g14.look(t)
state.pump(t)
for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} {
if got, ok := shanks.read(t, project, file); !ok || got != want {
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
}
}
before := state.rev
shanks.look(t)
g14.look(t)
state.pump(t)
if state.rev != before {
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
}
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
c.now = c.now.Add(time.Minute)
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
shanks.look(t)
state.pump(t)
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
t.Fatalf("the change did not reach g14: %q", got)
}
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
t.Fatal("the version replaced on g14 was not kept")
}
if len(g14.notices)+len(shanks.notices) != 0 {
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
}
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
raw, _ := json.Marshal(g14.Status())
if strings.Contains(string(raw), "accepted") {
t.Fatalf("the memory tool answered the text: %s", raw)
}
}
// §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are
// listed with why.
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
g14.write(t, project, "MEMORY.md", "index\n", c.now)
for path, text := range map[string]string{
filepath.Join(projectDir, "session.jsonl"): "{}\n",
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
} {
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
}
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
outside := filepath.Join(t.TempDir(), "elsewhere.md")
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
t.Fatal(err)
}
g14.look(t)
state.pump(t)
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
if _, ok := shanks.read(t, project, file); ok {
t.Errorf("%s was carried", file)
}
}
for _, file := range []string{"MEMORY.md", "hash.md"} {
if _, ok := shanks.read(t, project, file); !ok {
t.Errorf("%s was not carried", file)
}
}
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
t.Error("a session was carried")
}
for _, f := range []string{"settings.json", "history.jsonl"} {
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
t.Errorf("%s was carried", f)
}
}
keys, _ := state.Keys()
if len(keys) != 2 {
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
}
not, _ := g14.Status()["not carried"].(map[string]string)
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
if not[project+"/"+file] == "" {
t.Errorf("%s is not listed as not carried: %v", file, not)
}
}
}
// §4: the operator's homes and an agent account's homes never take each other's memory.
func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
root := t.TempDir()
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
control := newMemNode(t, root, "novox", HomeAgent, state, c)
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
control.look(t)
state.pump(t)
if _, ok := laptop.read(t, project, "MEMORY.md"); ok {
t.Fatal("a confined agent's memory reached the operator's home")
}
laptop.write(t, project, "MEMORY.md", "the operator's\n", c.now)
laptop.look(t)
state.pump(t)
if got, _ := control.read(t, project, "MEMORY.md"); got != "from a confined agent\n" {
t.Fatalf("the operator's memory reached the agent account's home: %q", got)
}
// A value of the other kind put under this kind's key is refused too.
key := MemoryKey(HomeOperator, project, "x.md")
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
if err := laptop.Take(key, "put", raw, 99); err == nil {
t.Fatal("a value of another kind was taken")
}
// Which kind a node carries for.
if k, _ := HomeKind(Paths{Agent: "jochen", Operator: "jochen", Home: "/home/jochen"}); k != HomeOperator {
t.Fatalf("the operator's own home is %q", k)
}
if k, _ := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/agent", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != HomeAgent {
t.Fatalf("an agent account's home is %q", k)
}
if k, why := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/jochen", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != "" || why == "" {
t.Fatalf("a confined agent in the operator's home carries %q", k)
}
}
// §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with
// one warning; it clears once the file is tidied.
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute))
c.now = c.now.Add(3 * time.Minute)
g14.look(t)
shanks.look(t)
state.pump(t)
g14.look(t)
shanks.look(t)
state.pump(t)
a, _ := g14.read(t, project, "MEMORY.md")
b, _ := shanks.read(t, project, "MEMORY.md")
if a != b {
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b)
}
newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14")
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
}
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
t.Fatalf("the line does not name both nodes:\n%s", a)
}
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
t.Fatalf("not one warning: %v", notices)
}
for _, field := range []string{"headline", "explanation", "summary", "resolved"} {
s, _ := notices[0][field].(string)
if s == "" || strings.ContainsAny(s, "/~:") && field != "explanation" || strings.Contains(s, ".md") {
t.Errorf("the warning's %s is empty or names a path: %q", field, s)
}
// The plain rule (ADR 0253 §3): it never sends the operator elsewhere.
if field != "summary" && (strings.Contains(strings.ToLower(s), "agent") || strings.Contains(strings.ToLower(s), "session")) {
t.Errorf("the warning's %s sends the operator elsewhere: %q", field, s)
}
}
if e, _ := notices[0]["explanation"].(string); !strings.HasPrefix(e, "Needs you: ") {
t.Errorf("the explanation does not open with its verdict: %q", e)
}
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
t.Errorf("the headline is longer than sixty characters: %q", h)
}
all := append(g14.copies(t), shanks.copies(t)...)
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
if !holdsText(all, v) {
t.Errorf("the version %q was not kept", v)
}
}
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
t.Fatalf("the conflict is not listed: %v", st)
}
// Tidied on either node, the warning clears.
c.now = c.now.Add(time.Minute)
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
g14.look(t)
state.pump(t)
cleared := false
for _, n := range append(g14.notices, shanks.notices...) {
if n["clear"] == true {
cleared = true
}
}
if !cleared {
t.Fatal("the warning did not clear once the file was tidied")
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
t.Fatalf("the tidied file did not reach shanks: %q", got)
}
}
// The order of the two versions: the later change first, equal times by node name.
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
if strings.Index(m, "new") > strings.Index(m, "old") {
t.Fatalf("the newer is not first: %s", m)
}
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
t.Fatalf("equal times are not ordered by node name: %s", m)
}
}
// §9: a removal moves the other node's unchanged file into the copies.
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
if err := os.Remove(g14.path(project, "old.md")); err != nil {
t.Fatal(err)
}
g14.look(t)
state.pump(t)
if _, ok := shanks.read(t, project, "old.md"); ok {
t.Fatal("the removal did not reach shanks")
}
if !holdsText(shanks.copies(t), "an old note\n") {
t.Fatal("the removed version was not kept on shanks")
}
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
var v MemoryVersion
_ = json.Unmarshal(raw, &v)
if !ok || !v.Removed || v.Text != "" {
t.Fatalf("the removal is not a marker in the state: %s", raw)
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("another file was touched")
}
}
// §9: a change outlives a removal, whichever reaches the state first.
func TestAChangeOutlivesARemoval(t *testing.T) {
for _, removalFirst := range []bool{true, false} {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
_ = os.Remove(g14.path(project, "note.md"))
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
if removalFirst {
g14.look(t)
state.pump(t)
shanks.look(t)
} else {
shanks.look(t)
g14.look(t)
}
state.pump(t)
g14.look(t)
shanks.look(t)
state.pump(t)
for _, n := range []*memNode{g14, shanks} {
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
t.Fatalf("removal first %v: the change did not outlive the removal on %s: %q", removalFirst, n.Node, got)
}
}
}
}
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
key := MemoryKey(HomeOperator, project, "MEMORY.md")
// The folder gone on g14.
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
t.Fatal(err)
}
g14.look(t)
state.pump(t)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("a missing folder removed the file elsewhere")
}
// A deleted key.
_ = state.Delete(key)
state.pump(t)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("a deleted key removed the file")
}
// The state lost: the file is carried again, not removed.
shanks.look(t)
state.pump(t)
if _, _, ok, _ := state.Get(key); !ok {
t.Fatal("a file whose key the state lost was not carried again")
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatalf("the file was touched on shanks: %q", got)
}
}
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
shanks.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
shanks.write(t, project, "workstation.md", "only on the workstation\n", c.now)
shanks.write(t, project, "same.md", "same\n", c.now)
g14.write(t, project, "same.md", "same\n", c.now)
g14.look(t)
state.pump(t)
shanks.look(t)
state.pump(t)
g14.look(t)
state.pump(t)
for _, n := range []*memNode{g14, shanks} {
index, _ := n.read(t, project, "MEMORY.md")
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
t.Fatalf("%s's index does not hold both, the newer first:\n%s", n.Node, index)
}
for file, want := range map[string]string{"laptop.md": "only on the laptop\n", "workstation.md": "only on the workstation\n", "same.md": "same\n"} {
if got, _ := n.read(t, project, file); got != want {
t.Fatalf("%s's %s: %q", n.Node, file, got)
}
}
}
if n := len(g14.notices) + len(shanks.notices); n != 1 {
t.Fatalf("%d warnings for one conflict", n)
}
}
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
elsewhere := t.TempDir()
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
t.Fatal(err)
}
g14.write(t, project, "MEMORY.md", "index\n", c.now)
g14.look(t)
state.pump(t)
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
t.Fatalf("written through a symbolic link: %v", entries)
}
}
// The manifest declares the state and the tool; the tool reads and never writes.
func TestTheManifestDeclaresTheMemoryState(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
State []string `json:"state"`
}
_ = json.Unmarshal(raw, &m)
if !holdsText(m.State, MemoryState) {
t.Fatalf("the manifest does not declare the %q state: %v", MemoryState, m.State)
}
var carrier *MemoryCarrier
if st := carrier.Status(); st["carrying"] != false {
t.Fatalf("a carrier not started says it carries: %v", st)
}
}
// replay hands one node the whole current set, as a watch begins when the node comes back.
func (s *memState) replay(t *testing.T, n *memNode) {
t.Helper()
keys, _ := s.Keys()
for _, k := range keys {
raw, rev, _, _ := s.Get(k)
if err := n.Take(k, "put", raw, rev); err != nil {
t.Logf("%s taking %s: %v", n.Node, k, err)
}
}
if err := n.Drain(); err != nil && !errors.Is(err, errChanged) {
t.Logf("%s applying: %v", n.Node, err)
}
}
// §6: a node that was away while a file changed twice takes the newest version, whose base it never saw, because
// its own file holds no change it did not carry; the version replaced is kept.
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n")
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
for _, v := range []string{"v2\n", "v3\n"} {
c.now = c.now.Add(time.Minute)
g14.write(t, project, "MEMORY.md", v, c.now)
g14.look(t)
state.pump(t)
}
state.watchers = append(state.watchers, shanks.MemoryCarrier)
state.replay(t, shanks)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" {
t.Fatalf("the node that was away holds %q", got)
}
if !holdsText(shanks.copies(t), "v1\n") {
t.Fatal("the version replaced was not kept")
}
if len(g14.notices)+len(shanks.notices) != 0 {
t.Fatal("taking a newer version warned")
}
}
// §2, §7: two versions too large to share one file are kept in two: the newer in the file, the older beside it,
// both carried; and a text that would not fit in one value of the state is not carried.
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n")
older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n"
newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n"
g14.write(t, project, "big.md", older, c.now.Add(time.Minute))
shanks.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
c.now = c.now.Add(3 * time.Minute)
g14.look(t)
shanks.look(t)
state.pump(t)
g14.look(t)
shanks.look(t)
state.pump(t)
beside := OlderBeside("big.md", "g14")
if beside != "big.older-from-g14.md" {
t.Fatalf("the older is named %q", beside)
}
for _, n := range []*memNode{g14, shanks} {
if got, _ := n.read(t, project, "big.md"); got != newer {
t.Fatalf("%s's file does not hold the newer version", n.Node)
}
if got, _ := n.read(t, project, beside); got != older {
t.Fatalf("%s does not hold the older version beside it", n.Node)
}
}
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") {
t.Fatalf("not one warning saying the older is beside it: %v", notices)
}
// Under the size limit, but too large once encoded: not carried, and said.
g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
g14.look(t)
state.pump(t)
if _, ok := shanks.read(t, project, "angles.md"); ok {
t.Fatal("a text too large for one value was carried")
}
not, _ := g14.Status()["not carried"].(map[string]string)
if not[project+"/angles.md"] == "" {
t.Fatalf("it is not listed as not carried: %v", not)
}
}
// stateHolds says whether any value of the state holds s.
func (s *memState) holds(sub string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, v := range s.values {
var mv MemoryVersion
_ = json.Unmarshal(v, &mv)
if strings.Contains(mv.Text, sub) {
return true
}
}
return false
}
// putRaw puts a value straight into the state, as a holder of a tool runner's credential could.
func (s *memState) putRaw(t *testing.T, v MemoryVersion) string {
t.Helper()
key := MemoryKey(v.Kind, v.Project, v.Path)
s.mu.Lock()
s.write(key, v)
s.mu.Unlock()
return key
}
// Review of #276, 1: a value whose project or path leads out of a memory folder is never taken, and nothing is
// written for it.
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
root := filepath.Dir(filepath.Dir(shanks.Home))
for _, bad := range []struct{ project, path string }{
{"../../../escaped", "x.md"}, {"..", "x.md"}, {"a/b", "x.md"}, {"", "x.md"},
{project, "../x.md"}, {project, "/abs.md"}, {project, "a//x.md"}, {project, "./x.md"}, {project, "a/../../x.md"},
} {
text := "planted\n"
state.putRaw(t, MemoryVersion{Kind: HomeOperator, Project: bad.project, Path: bad.path, Text: text,
Digest: digestOf([]byte(text)), Node: "elsewhere", Changed: c.now})
if NameProblem(bad.project, bad.path) == "" {
t.Errorf("%q %q is taken as a name", bad.project, bad.path)
}
}
state.pump(t)
g14.look(t)
shanks.look(t)
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
t.Errorf("a planted value was written at %s", p)
}
}
return nil
})
if NameProblem(project, "notes/a.md") != "" || NameProblem(project, "MEMORY.md") != "" {
t.Fatal("a memory file's own name is refused")
}
}
// Review of #276, 2: a file or a folder that cannot be read is kept everywhere, never carried as a removal, and is
// said as not carried.
func TestWhatCannotBeReadIsNeverTakenForGone(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads a file whatever its mode")
}
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
carriedOnBoth(t, state, c, g14, shanks, "notes/deep.md", "deep\n")
file, folder := g14.path(project, "MEMORY.md"), filepath.Dir(g14.path(project, "notes/deep.md"))
_ = os.Chmod(file, 0)
_ = os.Chmod(folder, 0)
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
g14.look(t)
state.pump(t)
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
if _, ok := shanks.read(t, project, f); !ok {
t.Errorf("%s, unreadable on g14, was removed on shanks", f)
}
}
not, _ := g14.Status()["not carried"].(map[string]string)
if len(not) == 0 {
t.Fatalf("nothing is said as not carried: %v", not)
}
keys, _ := state.Keys()
for _, k := range keys {
raw, _, _, _ := state.Get(k)
var v MemoryVersion
_ = json.Unmarshal(raw, &v)
if v.Removed {
t.Errorf("%s/%s was carried as a removal", v.Project, v.Path)
}
}
}
// Review of #276, 3: no text reaches the state without being screened, on the removal and the conflict paths too.
func TestNoUnscreenedTextReachesTheState(t *testing.T) {
token := "ghp_" + strings.Repeat("A1b2C3d4", 5) + "\n"
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
for _, text := range []string{token, big} {
// The removal path: removed on g14, changed on shanks to what is never carried.
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
_ = os.Remove(g14.path(project, "note.md"))
shanks.write(t, project, "note.md", text, c.now.Add(time.Minute))
g14.look(t)
state.pump(t)
if state.holds(text[:20]) {
t.Errorf("the removal path put text that is never carried into the state")
}
if got, _ := shanks.read(t, project, "note.md"); got != text {
t.Errorf("the change on shanks was not kept")
}
// The conflict path: changed on g14, and on shanks to what is never carried.
state, c, g14, shanks = twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
g14.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
shanks.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
g14.look(t)
state.pump(t)
shanks.look(t)
state.pump(t)
if state.holds(text[:20]) {
t.Errorf("the conflict path put text that is never carried into the state")
}
if got, _ := shanks.read(t, project, "note.md"); got != text {
t.Errorf("shanks's own version was not kept as it was")
}
}
}
// Review of #276, 4: a conflict whose file cannot be written leaves the state as it was, says so once, and merges
// once when the folder is writable again.
func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root writes a folder whatever its mode")
}
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "base\n")
g14.write(t, project, "MEMORY.md", "base\nfrom g14\n", c.now.Add(time.Minute))
shanks.write(t, project, "MEMORY.md", "base\nfrom shanks\n", c.now.Add(2*time.Minute))
folder := filepath.Dir(shanks.path(project, "MEMORY.md"))
_ = os.Chmod(folder, 0o500)
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
g14.look(t)
state.pump(t)
for i := 0; i < 5; i++ {
_ = shanks.Look()
state.pump(t)
}
if state.holds("<!-- novox mesh") {
t.Fatal("a merge that could not be written here reached the state")
}
unwritten := 0
for _, n := range shanks.notices {
if strings.HasSuffix(n["key"].(string), ".unwritten") {
unwritten++
}
}
if unwritten != 1 {
t.Fatalf("%d warnings that the file could not be written", unwritten)
}
_ = os.Chmod(folder, 0o700)
shanks.look(t)
state.pump(t)
g14.look(t)
state.pump(t)
for _, n := range []*memNode{g14, shanks} {
got, _ := n.read(t, project, "MEMORY.md")
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from g14") || !strings.Contains(got, "from shanks") {
t.Fatalf("%s does not hold both versions once:\n%s", n.Node, got)
}
}
}
// Review of #276, minor: a file that changed between its reading and its writing is not written over; text that is
// not UTF-8 is not carried.
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
_ = state
g14.write(t, project, "a.md", "read\n", c.now)
g14.write(t, project, "a.md", "changed meanwhile\n", c.now)
err := g14.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
if !errors.Is(err, errChangedHere) {
t.Fatalf("written over a change: %v", err)
}
if got, _ := g14.read(t, project, "a.md"); got != "changed meanwhile\n" {
t.Fatalf("the change was replaced: %q", got)
}
g14.write(t, project, "latin1.md", "caf\xe9\n", c.now)
g14.look(t)
state.pump(t)
if _, ok := shanks.read(t, project, "latin1.md"); ok {
t.Fatal("text that is not UTF-8 was carried")
}
not, _ := g14.Status()["not carried"].(map[string]string)
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
t.Fatalf("it is not said why: %v", not)
}
}
// §9: a node that was away takes a removal of a version newer than the one it holds, when it holds no change of
// its own: the removed file does not come back.
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "old.md", "v1\n")
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
c.now = c.now.Add(time.Minute)
g14.write(t, project, "old.md", "v2\n", c.now)
g14.look(t)
state.pump(t)
_ = os.Remove(g14.path(project, "old.md"))
g14.look(t)
state.pump(t)
state.watchers = append(state.watchers, shanks.MemoryCarrier)
state.replay(t, shanks)
shanks.look(t)
state.pump(t)
g14.look(t)
if _, ok := shanks.read(t, project, "old.md"); ok {
t.Fatal("the removal did not reach the node that was away")
}
if _, ok := g14.read(t, project, "old.md"); ok {
t.Fatal("the removed file came back")
}
if !holdsText(shanks.copies(t), "v1\n") {
t.Fatal("the version removed on the node that was away was not kept")
}
}
// slowState answers every read only once it is let go, as a state a whole bus away does under load.
type slowState struct {
*memState
gate chan struct{}
}
func (s slowState) Get(key string) (json.RawMessage, uint64, bool, error) {
<-s.gate
return s.memState.Get(key)
}
// Issue 367, seen live: the runtime hands a watch its current values one at a time and gives them all ten seconds;
// shanks's handler read the state and waited for its look for each of a few hundred values, so the watch never
// started and nothing written on g14 reached it. The handler now only notes a change: it returns at once while a
// look holds the carrier and while the state is slow to answer, and Drain applies what it noted.
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "index\n", c.now)
g14.look(t)
state.mu.Lock()
state.pending = nil
state.mu.Unlock()
slow := slowState{memState: state, gate: make(chan struct{})}
shanks.Store = slow
key := MemoryKey(HomeOperator, project, "MEMORY.md")
raw, rev, _, _ := state.Get(key)
shanks.mu.Lock() // a look running
done := make(chan error, 1)
start := time.Now()
for i := 0; i < 400; i++ {
go func() { done <- shanks.Take(key, "put", raw, rev) }()
}
for i := 0; i < 400; i++ {
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatalf("the watch handler waited for the look or the state (%d of 400 returned)", i)
}
}
if took := time.Since(start); took > time.Second {
t.Fatalf("400 values took %s to hand over", took)
}
shanks.mu.Unlock()
close(slow.gate)
if err := shanks.Drain(); err != nil {
t.Fatal(err)
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatalf("what the watch noted was not applied: %q", got)
}
}
@@ -130,7 +130,7 @@ func proposalTools(p Paths) []stdio.Tool {
text, _ = a["content"].(string)
}
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
Text: text, Remove: boolArg(a, "remove")}, proposing.Store, proposing.Bus, proposing.Now())
Text: text, Remove: boolArg(a, "remove"), Summary: strArg(a, "summary")}, proposing.Store, proposing.Bus, proposing.Now())
}},
{Name: "claude_code_proposals",
Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " +
@@ -164,14 +164,14 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
"operator's word to you; it authorises no call that needs the operator's approval.",
Input: map[string]any{
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
"context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"),
"context": str("what the operator decides and why it matters, in plain words: two or three short sentences, one line, at most 300 characters"),
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
"items": map[string]any{"type": "object", "properties": map[string]any{
"label": str("the button's words, at most 24 characters"),
"means": str("what you do when it is chosen, in plain words"),
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
}, "required": []string{"label", "means"}}},
"why": str("why the recommended option is the one you recommend"),
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
"expires_minutes": map[string]any{"type": "integer",
@@ -104,6 +104,12 @@ type Proposal struct {
Digest string `json:"digest"`
From string `json:"from"`
At time.Time `json:"at"`
// Summary is what the operator's phone shows of it, at a glance, in its proposer's words; empty, the module
// says what it does (novox/hq issue 639).
Summary string `json:"summary,omitempty"`
// Form is how its ask is composed, so every node composes the same one again to hold the warrant to it: 0 for
// a proposal made before issue 639 (its text in the explanation), AskGlance for one read at a glance.
Form int `json:"form,omitempty"`
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
Asked string `json:"asked,omitempty"`
}
@@ -164,9 +170,109 @@ func (p Proposal) Act(option string) asks.Act {
"at": p.At.UTC().Format(time.RFC3339Nano)}
}
// AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639).
const AskGlance = 1
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
// router's warrant to it.
// router's warrant to it — in the form it was asked in.
func (p Proposal) Ask() asks.Ask {
if p.Form == AskGlance {
return p.glanceAsk()
}
return p.listedAsk()
}
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
// Details, which the router notes after the answers, collapsed where the channel can.
func (p Proposal) glanceAsk() asks.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text)
part := ""
if p.Action != ActionRemove && !whole {
part = " Part shown"
}
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
if len([]rune(headline)) > asks.HeadlineLength {
headline = verb + " Claude instructions?" + part
}
var d strings.Builder
if p.Action == ActionRemove {
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.",
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint())
} else {
if whole {
d.WriteString("The text:\n" + shown + "\n")
} else {
d.WriteString("NOT SHOWN IN FULL here. Its first lines:\n" + shown + "\n")
}
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.",
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint())
}
fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID)
does := "the text becomes part of those sessions' instructions"
if p.Action == ActionRemove {
does = "the instructions are removed from those sessions"
}
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
}
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
func (p Proposal) summary() string {
if s := strings.TrimSpace(p.Summary); s != "" {
return s
}
lines := fmt.Sprintf("these %d lines", p.Lines())
if p.Lines() == 1 {
lines = "this line"
}
where := strings.ToUpper(p.where()[:1]) + p.where()[1:]
switch p.Action {
case ActionRemove:
return fmt.Sprintf("%s stops reading the instructions '%s' from its next start.", where, p.Name)
case ActionReplace:
return fmt.Sprintf("%s reads %s in place of the instructions '%s', as the mesh's own word, from its next start until "+
"they are removed the same way.", where, lines, p.Name)
}
return fmt.Sprintf("%s reads %s as the mesh's own word, from its next start until they are removed the same way.", where, lines)
}
// SummaryProblem says why a proposer's summary cannot be shown at a glance, or "".
func SummaryProblem(summary string) string {
switch s := strings.TrimSpace(summary); {
case s == "":
return ""
case strings.Contains(s, "\n"):
return "the summary is more than one line"
case len([]rune(s)) > asks.SummaryLength:
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
case !shownLine(s):
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
}
return ""
}
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
// options, each binding its act.
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name
if len(p.Nodes) == 0 {
a.About = "instructions." + p.Scope + "." + p.Name
}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", does},
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
} {
binds, _ := asks.ActDigest(p.Act(o.id))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
}
return a
}
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
// warrant of a proposal made then.
func (p Proposal) listedAsk() asks.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text)
part := ""
@@ -617,6 +723,8 @@ type ProposeInput struct {
Name string
Text string
Remove bool
// Summary is what the operator's phone shows of it, in one line (novox/hq issue 639); empty, the module says it.
Summary string
}
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
@@ -696,7 +804,11 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
return nil, err
}
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()}
if problem := SummaryProblem(in.Summary); problem != "" {
return refuse(problem)
}
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(),
Summary: strings.TrimSpace(in.Summary), Form: AskGlance}
switch {
case in.Remove:
if !isActive {
@@ -147,14 +147,17 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
if err := a.Check(t0); err != nil {
t.Fatalf("the router would refuse the ask: %v", err)
}
if !strings.HasPrefix(a.Explanation, "Add instructions 'handoff-from-g14' to every Claude session on the mesh?") {
t.Errorf("the phone does not say what is asked first: %q", a.Explanation)
// At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
// router notes collapsed after the answers.
if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." ||
asks.Glance(a.Explanation) != nil {
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
}
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
t.Errorf("headline: %q", a.Headline)
}
if !strings.Contains(a.Explanation, "The whole text:\n\n"+strings.TrimRight(handover, "\n")) {
t.Errorf("a short text is shown whole: %q", a.Explanation)
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") {
t.Errorf("a short text is not shown whole: %q", a.Details)
}
if len(a.Options) != 2 {
t.Fatalf("options: %+v", a.Options)
@@ -294,7 +297,7 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
t.Fatal(err)
}
rid := out["proposed"].(string)
if !strings.HasPrefix(r.published[rid].Explanation, "Remove the instructions 'handoff-from-g14' from every Claude session on the mesh?") {
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
t.Errorf("removal ask: %q", r.published[rid].Explanation)
}
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
@@ -326,7 +329,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
t.Fatal(err)
}
id := out["proposed"].(string)
if !strings.HasPrefix(r.published[id].Explanation, "Add instructions 'builds' to every Claude session on shanks?") {
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
t.Errorf("%q", r.published[id].Explanation)
}
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
@@ -417,15 +420,16 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
}
id := out["proposed"].(string)
a := r.published[id]
if strings.Contains(a.Explanation, "/etc/claude-code") || !strings.Contains(a.Explanation, "[1 line(s) not shown here") {
t.Errorf("a line with a path was shown: %q", a.Explanation)
if strings.Contains(a.Details, "/etc/claude-code") || !strings.Contains(a.Details, "[1 line(s) not shown here") ||
!strings.HasPrefix(a.Details, "NOT SHOWN IN FULL here.") || !strings.HasSuffix(a.Headline, "Part shown") {
t.Errorf("a line with a path was shown, or the cut not said: %q", a.Details)
}
if !strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") ||
!strings.Contains(a.Explanation, "fingerprint there must be "+out["fingerprint"].(string)) {
t.Errorf("the phone does not say where to read all of it: %q", a.Explanation)
if !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals") ||
!strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
t.Errorf("the phone does not say where to read all of it: %q", a.Details)
}
if len(a.Explanation) > 3000 {
t.Errorf("the explanation is %d bytes", len(a.Explanation))
if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
}
list, err := ListProposals(p, store, r, id, t0)
if err != nil {
@@ -739,3 +743,33 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
t.Errorf("decided %q", got)
}
}
// A proposer's summary is the explanation, held to a glance; a proposal made before issue 639 is composed as it was
// asked, so the warrant for it still holds after the change.
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
if err != nil {
t.Fatal(err)
}
id := out["proposed"].(string)
a := r.published[id]
if a.Explanation != "Every session learns how hand-overs are written, so the next one reads them the same way." {
t.Errorf("the proposer's summary is not the explanation: %q", a.Explanation)
}
for _, bad := range []string{strings.Repeat("word ", 70), "one\ntwo", "Read it in /etc/claude-code first."} {
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "other", Text: handover, Summary: bad}, store, r, t0); err == nil ||
!strings.Contains(err.Error(), "the summary") {
t.Errorf("the summary %q was taken: %v", bad, err)
}
}
prop, _ := readProposal(store, id)
older := prop
older.Form = 0
listed := older.Ask()
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() {
t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation)
}
}
@@ -69,9 +69,9 @@ const (
// The bounds of what a question says.
const (
FewestQuestionOptions = 2
MostContextBytes = 1500
MostContextBytes = asks.SummaryLength
MostMeansBytes = 200
MostWhyBytes = 300
MostWhyBytes = 120
MostOnExpiryBytes = 200
)
@@ -131,6 +131,8 @@ type Question struct {
From string `json:"from"`
At time.Time `json:"at"`
Expires time.Time `json:"expires"`
// Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked.
Form int `json:"form,omitempty"`
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
Asked string `json:"asked,omitempty"`
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
@@ -160,8 +162,41 @@ func (q Question) Act(option string) asks.Act {
return act
}
// Ask is the ask for a question, composed from it alone.
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
func (q Question) Ask() asks.Ask {
if q.Form == AskGlance {
return q.glanceAsk()
}
return q.listedAsk()
}
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
// answer and why as its one point; who asked and what the answer does are its Details.
func (q Question) glanceAsk() asks.Ask {
explanation := strings.TrimSpace(q.Context)
if rec, ok := q.recommended(); ok {
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
}
details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+
"to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
}
func (q Question) withOptions(a asks.Ask) asks.Ask {
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry
for _, o := range q.Options {
does := o.Means
if o.Recommended {
does = "Recommended. " + does
}
binds, _ := asks.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
}
return a
}
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
func (q Question) listedAsk() asks.Ask {
var b strings.Builder
b.WriteString(strings.TrimSpace(q.Context))
if rec, ok := q.recommended(); ok {
@@ -199,8 +234,10 @@ func QuestionProblem(q Question) string {
switch {
case strings.TrimSpace(q.Context) == "":
add("it gives no context: say in plain words what the operator decides and why it matters")
case len(q.Context) > MostContextBytes:
add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes)
case len([]rune(q.Context)) > MostContextBytes:
add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes)
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
}
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
@@ -322,7 +359,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
}
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
Expires: now.UTC().Add(life)}
Expires: now.UTC().Add(life), Form: AskGlance}
for i, o := range in.Options {
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
@@ -68,10 +68,13 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
}
for _, want := range []string{"Recommended: Merge now, because it is reviewed and the checks are green.",
"the agent session fix-589 on g14", "performs nothing by itself"} {
if !strings.Contains(a.Explanation, want) {
t.Errorf("the explanation does not say %q:\n%s", want, a.Explanation)
// At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
}
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
t.Errorf("the details do not say %q:\n%s", want, a.Details)
}
}
if err := a.Check(t0); err != nil {
+1 -1
View File
@@ -2,6 +2,6 @@ module claude-code
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
require gopkg.in/yaml.v3 v3.0.1
+8 -4
View File
@@ -1,7 +1,11 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg=
git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+3 -1
View File
@@ -19,7 +19,8 @@
"holdings",
"config",
"proposals",
"questions"
"questions",
"memory"
],
"reads": [
"claude-licence-manager.bindings"
@@ -32,6 +33,7 @@
"claude_code_grant",
"claude_code_add_api_key",
"claude_code_registrations",
"claude_code_memory",
"claude_code_mcp_list",
"claude_code_mcp_register",
"claude_code_mcp_unregister",
@@ -233,7 +233,13 @@ func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
case m.Silent:
urgency = "low"
}
args := map[string]any{"summary": m.Title, "body": html.EscapeString(m.Body), "urgency": urgency, "app_name": "mesh"}
// The note — who asked, what each answer does, the expiry — follows the body after a blank line: a
// notification has no smaller text, and the note is never left out (novox/hq issue 639).
text := strings.TrimSpace(m.Body)
if note := strings.TrimSpace(m.Note); note != "" {
text = strings.TrimSpace(text + "\n\n" + note)
}
args := map[string]any{"summary": m.Title, "body": html.EscapeString(text), "urgency": urgency, "app_name": "mesh"}
if replace != 0 {
args["replace_id"] = replace
}
@@ -103,6 +103,16 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
}
}
// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639).
func TestTheNoteFollowsTheBody(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." {
t.Errorf("the body: %q", last.args["body"])
}
}
func TestAnEditReplacesTheNotification(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
+1 -1
View File
@@ -2,4 +2,4 @@ module deskchannel
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.10
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
+6
View File
@@ -1,2 +1,8 @@
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+7
View File
@@ -4,6 +4,13 @@ The forge, and the mesh's npm package registry: it claims the `git` and `npm-pac
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`,
`tools/`, `provisioner/`). This README covers the two Go bundles beside it.
**What a registry consumer is told** (`serves.npm-package-registry`): where the registry answers on the
private network (`scheme`, `port`, `npm-path`), and its `public-root`, the address the forge writes every
tarball at: `https://` and the forge's route's composed public name, under the npm path
(`${bound:route:name-web}`, novox/hq ADR 0346). A lock file holds that address, and npm 12 installs a
locked tarball only from the registry it is configured with, so a consumer that installs from lock files
trusts that root. On a machine with no public domain the key is left out, and the plan says so.
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
+2 -1
View File
@@ -68,7 +68,8 @@
"npm-package-registry": {
"scheme": "http",
"port": 3000,
"npm-path": "/api/packages/novox/npm/"
"npm-path": "/api/packages/novox/npm/",
"public-root": "https://${bound:route:name-web}/api/packages/novox/npm/"
},
"git": {
"scheme": "http",
+21 -3
View File
@@ -26,12 +26,24 @@
"why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant"
}
],
"settings": {
"org": {
"kind": "preference",
"default": "mesh",
"why": "the organisation influxdb-api names to its consumers; mesh is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)"
},
"bucket": {
"kind": "preference",
"default": "default",
"why": "the bucket influxdb-api names to its consumers; default is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)"
}
},
"serves": {
"influxdb-api": {
"scheme": "http",
"port": 8086,
"org": "mesh",
"bucket": "default"
"org": "${setting:org}",
"bucket": "${setting:bucket}"
}
},
"receives": {
@@ -124,7 +136,13 @@
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
"merge": "json",
"takes-settings": [
"bucket",
"org",
"url"
],
"trusted": true
}
],
"requires": [
+3 -1
View File
@@ -104,7 +104,9 @@
],
"env": {
"MESH_LAB_WORK": "${dir:work}",
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env",
"MESH_LAB_FORGE_BINDING": "${dir:state}/package-registry.json",
"MESH_LAB_FORGE_SECRET": "${dir:state}/package-registry.secret"
}
}
]
+187
View File
@@ -194,3 +194,190 @@ test("the lab asks the package registry for read only", () => {
assert.ok(m.requires.includes("npm-package-registry"));
assert.equal(m.contributes?.["npm-package-registry"]?.access, "read");
});
// lab_check failed for ever on "destination path '<work>/check' already exists": it cloned into one fixed
// path and ignored whether removing the last one worked (novox/hq issue 624). Each check now works in a
// fresh directory, and what earlier checks left is cleared or named.
import { checkDir, clearChecks } from "../tools/runs.ts";
test("each check gets a fresh directory of its own", () => {
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
const a = checkDir(work);
const b = checkDir(work);
assert.notEqual(a, b);
assert.ok(a.startsWith(join(work, "check-")) && existsSync(a) && existsSync(b));
rmSync(work, { recursive: true, force: true });
});
// As root nothing is locked against removal, so the unremovable tree cannot be made: skipped there, before
// it makes anything (the merge check runs as root).
test("what earlier checks left is cleared, and what cannot be is named rather than tripped over", {
skip: process.getuid?.() === 0 ? "running as root, which no directory mode stops from removing anything" : false,
}, () => {
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
for (const d of ["check/src", "check-setup/mesh-lab", "check-abc", "run-x", "keep"]) mkdirSync(join(work, d), { recursive: true });
// A tree this account cannot empty: a directory it may not write, with something in it.
mkdirSync(join(work, "check-stuck", "locked"), { recursive: true });
writeFileSync(join(work, "check-stuck", "locked", "file"), "x");
chmodSync(join(work, "check-stuck", "locked"), 0o555);
const said = clearChecks(work);
chmodSync(join(work, "check-stuck", "locked"), 0o755);
for (const gone of ["check", "check-setup", "check-abc"]) assert.equal(existsSync(join(work, gone)), false, gone);
assert.ok(existsSync(join(work, "run-x")) && existsSync(join(work, "keep")));
assert.equal(said.length, 1);
assert.match(said[0] as string, /check-stuck, left by an earlier check, could not be removed/);
rmSync(work, { recursive: true, force: true });
});
// And as root too: what earlier checks left is cleared.
test("leftover checks are cleared, whoever runs the check", () => {
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
for (const d of ["check/src", "check-setup/mesh-lab", "check-abc", "run-x"]) mkdirSync(join(work, d), { recursive: true });
assert.deepEqual(clearChecks(work), []);
for (const gone of ["check", "check-setup", "check-abc"]) assert.equal(existsSync(join(work, gone)), false, gone);
assert.ok(existsSync(join(work, "run-x")));
rmSync(work, { recursive: true, force: true });
});
// The lab's tools read the bound credential, so the runtime is given that file by the tools bundle's env
// (ADR 0192), as gitea and umami are given theirs: the runtime's account may then read exactly it, and no
// other credential changes hands (issue 624).
test("the lab's tools bundle names its bound credential and binding in its env", () => {
const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as {
secrets: Record<string, string>; binds: Record<string, string>;
build: { artifacts: { name: string; env?: Record<string, string> }[] };
};
const env = m.build.artifacts.find((a) => a.name === "code")?.env ?? {};
assert.equal(env.MESH_LAB_FORGE_SECRET, m.secrets["npm-package-registry"]);
assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]);
});
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused
// mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's
// address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball
// URL at (https://git.novox.be/…), and npm 12 installs a locked tarball only from its configured registry.
// A run names the registry by the address the registry names itself by.
import { npmRegistry } from "../tools/runs.ts";
function aRegistry(answer: () => Response | Promise<Response>): { get: typeof fetch; asked: string[] } {
const asked: string[] = [];
const get = (async (url: string | URL | Request) => {
asked.push(String(url));
return answer();
}) as typeof fetch;
return { get, asked };
}
const packument = (tarball: string) => new Response(JSON.stringify({
name: "@novox/mesh-sdk", "dist-tags": { latest: "0.1.14" },
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/novox/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
}), { status: 200, headers: { "content-type": "application/json" } });
test("a run names the package registry by the address the registry gives its tarballs", async () => {
const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/");
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" });
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]);
// The forge's own scheme is taken too, with its port.
const plain = await npmRegistry("http://forge.example.org:3000",
aRegistry(() => packument("http://forge.example.org:3000/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/novox/npm/" });
});
// What the registry answers may come over plain HTTP, so it never decides where packages come from: only
// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after
// it (review of mesh-catalog #275, ADR 0346).
test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => {
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
for (const [answer, why] of [
[() => new Response("not found", { status: 404 }), /it answered 404/],
[() => new Response("{}", { status: 200 }), /no tarball/],
[() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/],
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//],
[() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/],
[() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/],
[() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/],
[() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/],
[() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/],
[() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/],
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/],
[() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
[() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/],
] as [() => Response, RegExp][]) {
const got = await npmRegistry("http://novox.internal:20000", aRegistry(answer).get);
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", String(why));
assert.match(got.problem ?? "", /did not give an address the lab takes/);
assert.match(got.problem ?? "", why);
}
});
test("a run's npm configuration names the registry it was given, and the forge's own address only without one", () => {
const given = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, undefined,
"https://forge.example.org/api/packages/novox/npm/");
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/novox\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
assert.doesNotMatch(given, /@novox:registry=http:\/\/novox\.internal/);
const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {});
assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/);
});
// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a
// tarball may come from, by scheme, host and port: the review of #275 found the port ignored.
import { publicRootOf } from "../tools/runs.ts";
test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => {
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
const root = "https://git.example.org/api/packages/novox/npm/";
const at = (tarball: string, publicRoot?: string) =>
npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot);
assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root });
for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) {
const got = await at(tarball, root);
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball);
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball);
}
// Without a public root, the forge's public name is not trusted on its own word.
assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/);
// And the forge's own address only at its own port.
assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/);
});
test("the public root is read from the binding, and only an https root at the npm path is taken", () => {
const dir = mkdtempSync(join(tmpdir(), "lab-root-"));
const binding = (root: unknown) => {
const f = join(dir, `b-${Math.random()}.json`);
writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000,
"npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
return f;
};
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")),
{ root: "https://git.example.org/api/packages/novox/npm/" });
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")),
{ root: "https://git.example.org:8443/api/packages/novox/npm/" });
for (const [root, why] of [
[undefined, /serves no public-root/],
["http://git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/],
["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/],
] as [unknown, RegExp][]) {
const got = publicRootOf(binding(root));
assert.equal(got.root, undefined, String(root));
assert.match(got.problem ?? "", why, String(root));
}
assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/);
assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/);
rmSync(dir, { recursive: true, force: true });
});
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
// is "null", as is a `file:` forge's, and the two must not match.
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
const root = "https://git.example.org/api/packages/novox/npm/";
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
assert.match(viaRoot.problem ?? "", /neither/);
});
+31 -10
View File
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
cleanCredentials, credentialArgs, forgeCredential, installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start,
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
stop, tail, writeCredential,
} from "./runs.js";
@@ -19,6 +19,14 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
// The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call.
const bindingOf = (): string | undefined =>
env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined);
const registryOf = async (forge: string) => {
const root = publicRootOf(bindingOf());
const registry = await npmRegistry(forge, fetch, root.root);
return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry;
};
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
env.MESH_LAB_FORGE_BINDING ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING"),
env.MESH_LAB_FORGE_SECRET ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_SECRET"));
@@ -30,12 +38,24 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
run: async () => {
const forge = forgeOf();
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const dir = `${work}/check`;
spawnSync("rm", ["-rf", dir]);
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
if (clone.status !== 0) return { ok: false, output: clone.stderr };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
// What earlier checks left is cleared, and what could not be is said; this check works in a fresh
// directory of its own either way, and removes it when it is done.
const leftovers = clearChecks(work);
const dir = checkDir(work);
let check: ReturnType<typeof spawnSync>;
let problems: string[];
try {
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge));
// And the address a run names the package registry by (npmRegistry): said when the registry does not say it.
const registry = await registryOf(forge);
if (registry.problem) problems.push(registry.problem);
} finally {
rmSync(dir, { recursive: true, force: true });
}
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
// repository it clones answers on the forge, and every one it installs has a lock file where the run
// installs from.
@@ -43,9 +63,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
// by a running run is not.
const busy = running(work);
const left = cleanCredentials(work, busy ? `${work}/.${busy.id}.git-credentials` : undefined);
const problems = setupProblems(forge, `${work}/check-setup`, credentialOf(forge));
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run left behind: ${left.join(", ")}` : "";
const output = [`${check.stdout}${check.stderr}`.trim(), removed, ...problems].filter(Boolean).join("\n");
const output = [`${check.stdout}${check.stderr}`.trim(), removed, ...leftovers, ...problems].filter(Boolean).join("\n");
return { ok: check.status === 0 && problems.length === 0, output };
},
},
@@ -79,7 +98,9 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge)) };
const registry = await registryOf(forge);
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge), registry.registry),
registry: registry.registry, ...(registry.problem ? { problem: registry.problem } : {}) };
},
},
{
+140 -7
View File
@@ -7,7 +7,7 @@
// for it, and says it was lost rather than pretending it is still going.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/**
@@ -60,6 +60,108 @@ export function installStep(root: string, build: boolean): string {
fi`;
}
/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */
export const NPM_PATH = "/api/packages/novox/npm/";
/**
* The registry's public root, as the forge serves it in the lab's npm-package-registry binding
* (`serves.public-root`, novox/hq ADR 0346): the forge's route's composed public name under the npm path.
* The mesh writes the binding, and it is checked all the same: an https URL whose path is the npm path and
* nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a
* machine with no public domain serves none (ADR 0346 §3).
*/
export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } {
if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" };
let said: unknown;
try {
said = (JSON.parse(readFileSync(bindingFile, "utf8")) as { serves?: Record<string, unknown> }).serves?.["public-root"];
} catch (e) {
return { problem: `its npm-package-registry binding could not be read: ${e instanceof Error ? e.message : String(e)}` };
}
if (typeof said !== "string" || !said) return { problem: "its npm-package-registry binding serves no public-root" };
if (/[\s\u0000-\u001f\u007f]/.test(said)) return { problem: "the binding's public-root holds whitespace or a control character" };
let u: URL;
try {
u = new URL(said);
} catch {
return { problem: `the binding's public-root ${JSON.stringify(said)} is not a URL` };
}
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash || u.pathname !== NPM_PATH) {
return { problem: `the binding's public-root ${JSON.stringify(said)} is not https://<host>${NPM_PATH}` };
}
return { root: `${u.origin}${NPM_PATH}` };
}
/**
* The address a run names the @novox registry by: the one the registry names itself by, when that is the
* forge's public root or the forge's own address (novox/hq issue 624, ADR 0346).
*
* **npm 12 installs a locked tarball only from the registry it is configured with.** Its `allow-remote`
* defaults to `none`, and a lock file's `resolved` URL counts as the registry's only when its origin and
* path are the configured scope registry's; any other is a "remote" tarball and refused (EALLOWREMOTE).
* The forge writes every tarball URL — in what it answers, and so in every lock file made against it — at
* its own public address, whichever address it was asked at. So the lab asks the registry, at the forge,
* for the SDK's newest tarball and names the registry by the origin of that URL.
*
* **What the registry answers is not trusted for where packages come from** (review of mesh-catalog #275):
* it may be fetched over plain HTTP. The tarball URL is parsed, and taken only when it carries no
* whitespace, control character, user, password, query or fragment, its path is under the npm path, and
* its origin — scheme, host and port — is one the lab already trusts: the public root the forge serves in
* the lab's binding (`publicRoot`, ADR 0346), or the forge setting's own. The registry is then built from
* the parsed origin and the npm path alone, never from the string answered, so nothing the registry says
* reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the
* forge's own address and says why, so a run still starts and lab_check names it.
*/
export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string):
Promise<{ registry: string; problem?: string }> {
const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`;
const url = `${asked}@novox%2fmesh-sdk`;
const fallback = (why: string) => ({
registry: asked,
problem: `the package registry at ${url} did not give an address the lab takes (${why}), so a run names it ${asked}, and npm 12 refuses a locked @novox tarball at any other`,
});
let own: URL;
try {
own = new URL(forge);
} catch {
return fallback(`the forge ${JSON.stringify(forge)} is not a URL`);
}
// The forge setting's own origin is trusted only as an http(s) one: any other scheme's origin is
// "null", or names no registry npm would fetch from.
const trusted = own.protocol === "https:" || own.protocol === "http:" ? [own.origin] : [];
if (publicRoot) {
try {
trusted.unshift(new URL(publicRoot).origin);
} catch {
// publicRootOf hands only a parsed root; a caller's other string is not trusted.
}
}
try {
const res = await get(url, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) return fallback(`it answered ${res.status}`);
const doc = await res.json() as { "dist-tags"?: { latest?: string }; versions?: Record<string, { dist?: { tarball?: unknown } }> };
const latest = doc["dist-tags"]?.latest ?? "";
const tarball = doc.versions?.[latest]?.dist?.tarball;
if (typeof tarball !== "string" || !tarball) return fallback("no tarball for its latest version");
if (/[\s\u0000-\u001f\u007f]/.test(tarball)) return fallback("its tarball URL holds whitespace or a control character");
let t: URL;
try {
t = new URL(tarball);
} catch {
return fallback("its tarball is not a URL");
}
if (t.username || t.password) return fallback("its tarball URL carries a user or password");
if (t.search || t.hash) return fallback("its tarball URL carries a query or a fragment");
if (!t.pathname.startsWith(NPM_PATH)) return fallback(`its tarball is not under ${NPM_PATH}`);
if (!trusted.includes(t.origin)) {
return fallback(`its tarball is at ${t.origin}, which is neither the forge's public root nor its own address (${trusted.join(", ")})`);
}
return { registry: `${t.origin}${NPM_PATH}` };
} catch (e) {
return fallback(e instanceof Error ? e.message : String(e));
}
}
/**
* The forge credential a run's clones may present, as a git credential-store line —
* scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none.
@@ -137,6 +239,37 @@ export function cleanCredentials(work: string, keep?: string): string[] {
return removed;
}
/**
* checkDir is a directory of lab_check's own for one check, fresh each time (novox/hq issue 624). The check
* cloned into the one fixed path `<work>/check`, removing it first and ignoring whether that worked; a tree
* an earlier check left that this account could not remove — one cloned as another account before the
* work directory was the operator's — made every check fail on "destination path already exists".
*/
export function checkDir(work: string): string {
mkdirSync(work, { recursive: true });
return mkdtempSync(join(work, "check-"));
}
/**
* clearChecks removes what earlier checks left in the work directory — the fixed `check` and `check-setup`
* of before, and any `check-*` — and says, for each it could not remove, which and why, so a check reports
* it rather than tripping over it.
*/
export function clearChecks(work: string): string[] {
if (!existsSync(work)) return [];
const said: string[] = [];
for (const name of readdirSync(work)) {
if (name !== "check" && name !== "check-setup" && !name.startsWith("check-")) continue;
const path = join(work, name);
try {
rmSync(path, { recursive: true, force: true });
} catch (err) {
said.push(`${path}, left by an earlier check, could not be removed: ${(err as Error).message}`);
}
}
return said;
}
/** The arguments that have git offer the credential in that file, and only that one. */
export function credentialArgs(path: string | undefined): string {
if (!path) return "";
@@ -209,7 +342,7 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
* at this run's own tree, so what is built and claimed is exactly what was checked out.
*/
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
credential?: string): string {
credential?: string, registry = `${forge}${NPM_PATH}`): string {
const dir = runDir(work, id);
const setState = (state: string) =>
`node -e ${shellQuote(
@@ -235,9 +368,9 @@ node -e ${shellQuote(
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
)}
${setState("building")}
# The @novox scope resolves from the mesh's own package registry on the forge, as the builder
# resolves it; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
# The @novox scope resolves from the mesh's own package registry on the forge, named by the address it
# names itself by (npmRegistry), which is the address its lock files hold; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
mkdir -p ${shellQuote(bin)}
@@ -263,7 +396,7 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
/** start begins a run and returns at once with its status. */
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
credential?: string): RunStatus {
credential?: string, registry?: string): RunStatus {
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = runDir(work, id);
mkdirSync(dir, { recursive: true });
@@ -272,7 +405,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record
// What a crashed or rebooted run left behind goes before this run writes its own.
cleanCredentials(work);
const credentials = writeCredential(credentialPath(work, id), credential);
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials), { mode: 0o700 });
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 });
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
@@ -0,0 +1,50 @@
package main
import (
"reflect"
"strings"
"testing"
"time"
)
// One build per change (novox/hq ADR 0350): a pull request closed unmerged has its check builds let go of, and a
// walk's record says per module whether its build reused a check's build or built, and why.
// A pull request closed unmerged asks the controller to let go of what its checks built for its merge (§5); one
// the controller cannot answer is said, never fatal.
func TestAPullRequestClosedUnmergedLetsGoOfItsCheckBuilds(t *testing.T) {
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atCC)
cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json")
w.h.PullUpdated(cc)
w.h.PullClosed(cc)
if want := []string{"novox/mesh-catalog#101"}; !reflect.DeepEqual(w.ctl.forgot, want) {
t.Fatalf("closed unmerged, the controller was asked to let go of %v", w.ctl.forgot)
}
if s := w.state(IDOf(cat, headCC)); s != Stopped {
t.Fatalf("closed unmerged it is %s", s)
}
w.ctl.down = true
du := pr(cat, 102, headDu, "feat/dunst", "modules/dunst/module.json")
w.h.PullUpdated(du)
w.h.PullClosed(du)
if s := w.state(IDOf(cat, headDu)); s != Stopped {
t.Fatalf("with the controller down, a closed delivery is %s", s)
}
}
// The walk's record says per module what its build did, and the delivery keeps it beside its walk (§4).
func TestAWalksRecordSaysWhetherEachModuleWasReusedOrBuilt(t *testing.T) {
walk := catWalk("plan-1", mergeCC, atCC.Add(time.Minute), "claude-code", "dunst")
walk.Modules["claude-code"].Made = "reused from the check of novox/mesh-catalog#101 at head cccccccccccc " +
"(merged into trunk 553b71910000): tools sha256:aa"
walk.Modules["dunst"].Made = "built: no check of novox/mesh-catalog#101 built it"
seen := walk.Seen()
if !strings.HasPrefix(seen.Built["claude-code"], "reused from the check of novox/mesh-catalog#101") ||
seen.Built["dunst"] != "built: no check of novox/mesh-catalog#101 built it" {
t.Fatalf("the delivery keeps %v", seen.Built)
}
if catWalk("plan-2", mergeCC, atCC, "dunst").Seen().Built != nil {
t.Fatal("a walk from a controller that says nothing of its builds reads as if it said something")
}
}
@@ -110,6 +110,18 @@ type fakeController struct {
onCheck func()
// planned is what Plan answers instead of "builds app", when set.
planned *DeliveryPlan
// forgot are the pull requests whose check builds were let go of (novox/hq ADR 0350 §5), "owner/repo#n".
forgot []string
}
func (c *fakeController) ForgetChecks(owner, repo string, number int) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return errors.New("down")
}
c.forgot = append(c.forgot, fmt.Sprintf("%s/%s#%d", owner, repo, number))
return nil
}
func newFakeController() *fakeController {
@@ -593,9 +593,12 @@ func verdictOfState(s string) string {
}
// PullClosed takes a pull request closed unmerged: its open deliveries are stopped.
//
// **And what its checks built for its merge goes** (novox/hq ADR 0350 §5): nothing will take it now. Asked of the
// controller once the deliveries are settled, outside the lock; a refusal is said, and the build seat's holders let
// go of it within the week anyway.
func (h *Holder) PullClosed(p PullEvent) {
h.mu.Lock()
defer h.mu.Unlock()
for _, d := range h.deliveries {
if d.Repository == p.repository() && d.Number == p.Number && !d.State.Final() && d.MergedAs == "" {
d.ClosedUnmerged = true
@@ -605,6 +608,16 @@ func (h *Holder) PullClosed(p PullEvent) {
}
}
}
h.mu.Unlock()
if h.Controller == nil || p.Owner == "" || p.Repo == "" || p.Number <= 0 {
return
}
if err := h.Controller.ForgetChecks(p.Owner, p.Repo, p.Number); err != nil {
h.mu.Lock()
h.refuse(fmt.Errorf("%s#%d closed, and its check builds stay until the week's sweep: %w", p.repository(),
p.Number, err))
h.mu.Unlock()
}
}
// CheckedEvent is the controller's `checked`.
@@ -61,7 +61,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
}
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
want := []string{}
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks"} {
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks", "forget-checks"} {
want = append(want, "seat:"+ControllerSeat+"."+v)
}
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
@@ -402,6 +402,9 @@ type WalkModule struct {
Build string `json:"build,omitempty"`
Gate *WalkGate `json:"gate,omitempty"`
GatedBy string `json:"gated_by,omitempty"`
// Made is what the walk's build of it did (novox/hq ADR 0350 §4): "reused from <check build>", naming the pull
// request's head, the trunk commit and the digests, or "built: <why>". Empty from a controller before it.
Made string `json:"made,omitempty"`
}
// WalkGate is a module's judging on its first machine.
@@ -592,6 +595,9 @@ type WalkSeen struct {
// Unchanged are the modules the walk said matched what runs, with why (novox/hq issue 611): not built, and
// nothing for it to deliver.
Unchanged map[string]string `json:"unchanged,omitempty"`
// Built is, per module, what the walk's build of it did (novox/hq ADR 0350 §4): "reused from <check build>" or
// "built: <why>" — what `show` says beside the build phase.
Built map[string]string `json:"built,omitempty"`
}
// Seen is a walk as a delivery keeps it.
@@ -616,6 +622,12 @@ func (w Walk) Seen() *WalkSeen {
if m != nil && (m.SentAt != nil || m.Gate != nil && m.Gate.Verdict == "passed") {
s.Moved = append(s.Moved, name)
}
if m != nil && m.Made != "" {
if s.Built == nil {
s.Built = map[string]string{}
}
s.Built[name] = m.Made
}
}
sort.Strings(s.Moved)
s.MovedSaid = true
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -70,6 +71,8 @@ type Controller interface {
StopWithout(walk, why, by string, without []string) (StopAnswer, error)
// Walks are the walks the controller keeps — one, given its id — and whether this seat has a holder on record.
Walks(walk string) (bool, []Walk, error)
// ForgetChecks lets go of the builds a pull request's merge checks made for its merge (novox/hq ADR 0350 §5).
ForgetChecks(owner, repo string, number int) error
}
// Forge is what the forge's holder does for a delivery.
@@ -271,6 +274,11 @@ func (c seatController) Deliver(walk, why string) error {
return err
}
func (c seatController) ForgetChecks(owner, repo string, number int) error {
_, err := c.verb("forget-checks", map[string]any{"owner": owner, "repo": repo, "number": strconv.Itoa(number)})
return err
}
func (c seatController) Stop(walk, why, by string) error {
_, err := c.verb("delivery-stop", map[string]any{"plan": walk, "why": why, "by": by})
return err
+1
View File
@@ -41,6 +41,7 @@
"seat:mesh-controller.deliver",
"seat:mesh-controller.delivery-stop",
"seat:mesh-controller.delivery-walks",
"seat:mesh-controller.forget-checks",
"gitea.gitea_note_append",
"gitea.gitea_delivery_view",
"gitea.gitea_commit_status",
+9 -3
View File
@@ -72,7 +72,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
## Writing a record
1. `reserve` a number for its kind.
2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **29 rules
2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **30 rules
that judge one record or its prose**. They are hq's checks moved to the write; `PerRecordRules` in `rules.go`
lists each one with the check it comes from:
- 25 on the structure: kind, reserved number, title, topic, date, deciders, research status, at least one
@@ -81,7 +81,10 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
supersedes or changes is accepted, a changed clause is in force or changed (a drafted one too), a superseded record cited beside its
superseder, a graduated effort says what it became, and the links in its text resolve;
- 4 on the prose: no word the glossary retired, no daemon's line, a deferral names its tracking issue, and a
progressive insight in the dated, marked form.
progressive insight in the dated, marked form;
- 1 on the operator's ask (hq issue 639): `brief`, a record that needs the operator carries a brief its ask reads
at a glance — a headline of at most 60 characters, a one-line summary of at most 300, at most three points of
at most 160, and no path, address, dotted name or long word. It holds records dated from 2026-10-13.
A rule holds a record from the day hq's checks began to hold it (`RuleFrom` in `rules.go`):
- retired words from 2026-10-07 and daemons' lines from 2026-10-09 for a research effort; for a decision record
@@ -122,7 +125,10 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
- On approve the record and its clauses are accepted and in force. Every clause it changes moves to
`changed`, and every record it supersedes moves to `superseded`, each as a new version of that record. A record marked `needs-operator` is first asked of the
operator (an ask that cannot be published is taken back, and the record is reviewed again later): an ask with
Approve and Decline, both at the level approve, each bound to this version's digest. It is
Approve and Decline, both at the level approve, each bound to this version's digest. The ask reads at a glance
(hq issue 639): the record's `brief` — a headline saying what it changes for the operator, a summary, at most three
points — and as its Details who proposed it, the review, the fingerprint and how to read its clauses; the clauses
are never listed. An ask made before that change is composed again as it was asked (`form`). It is
accepted only on the router's warrant for Approve, read back from the router's own record and held to the ask
composed again. A record changed after it was asked finds the digests differ, and nothing is accepted. The
warrant is kept with the record.
@@ -309,9 +309,11 @@ type Got struct {
Source string `json:"source,omitempty"`
Reserved string `json:"reserved,omitempty"`
NeedsOperat bool `json:"needs-operator,omitempty"`
Cut string `json:"cut,omitempty"`
Bound int `json:"bound"`
Read string `json:"read,omitempty"`
// Brief is what the operator's ask shows of it (issue 639).
Brief *Brief `json:"brief,omitempty"`
Cut string `json:"cut,omitempty"`
Bound int `json:"bound"`
Read string `json:"read,omitempty"`
// Reconstructed, Amended and Touches are the record's fields of those names; Fields, any other it holds as
// written (hq issue 487).
@@ -349,7 +351,7 @@ func (k *KB) Get(a GetAsk) (*Got, error) {
bound := boundOf(a.Bound)
g := &Got{Record: key.String(), Title: r.Title, Status: r.Status, Topic: r.Topic, Date: r.Date, Deciders: r.Deciders,
Version: version, Versions: e.Versions, Insights: r.Insights, Reviews: r.Reviews, Drafting: r.Drafting, Source: r.Source,
NeedsOperat: r.NeedsOperator, Bound: bound, Since: k.since(ctx, key),
NeedsOperat: r.NeedsOperator, Brief: r.Brief, Bound: bound, Since: k.since(ctx, key),
Reconstructed: r.Reconstructed, Amended: r.Amended, Touches: r.Touches, Fields: r.Extra}
if r.Status == StatusReserved || r.Status == StatusWithdrawn {
g.Reserved = fmt.Sprintf("reserved by %s at %s for %q", r.ReservedBy, r.ReservedAt.Format("2006-01-02 15:04 UTC"), r.ReservedFor)
@@ -0,0 +1,111 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// novox/hq issue 639: the operator would not read the ask for ADR 0343 — thirty plain lines, every clause one per
// line. A record that needs the operator is asked with its brief: a headline that says what it changes for them, a
// summary, at most three points; who proposed it, the fingerprint and how to read the clauses are the Details.
// adr0343 is ADR 0343 as it was asked on 2026-10-13: thirteen clauses, and the brief its author would now give.
func adr0343() *Record {
r := &Record{Kind: KindDecision, Number: 343, Status: StatusProposed, Date: "2026-10-13", NeedsOperator: true,
Title: "A session's memory follows the operator: the agent module carries each project's memory folder to every node, and a file changed on two keeps both",
Author: "fix-367@g14",
Brief: &Brief{Headline: "Your agents' memory follows you to every machine",
Summary: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory " +
"files are screened, though not perfectly.",
Points: []string{"Only memory folders travel: links and files over 128 KiB stay where they are",
"A conflict reaches you as a warning that needs you",
"A deleted file is marked, and a newer change elsewhere outlives the deletion"}}}
leads := []string{"Only the memory folders are carried", "Some memory files are not carried, and each is listed with why",
"The memory folders become paths the module's own process writes", "Memory moves only between homes of the same kind",
"The module's process on each node notices a change and puts it in the module's state",
"Every other node writes the new version where its own file holds no change it did not carry, and keeps the version replaced",
"When a file changed on two nodes, both versions are kept, the newer first",
"A conflict is sent to the operator as a warning that needs them",
"A deletion is carried as a removal marker, and a change outlives it",
"On a node's first carrying, every version learned before is kept", "One read-only tool shows what is carried",
"A value put straight into the state is not yet told apart from one the module put there", "How it is checked"}
for i, l := range leads {
r.Clauses = append(r.Clauses, Clause{N: i + 1, Text: "**" + l + ".** The statement.", Status: ClauseProposed, Source: SourceWritten})
}
return r
}
func TestADecisionAskReadsAtAGlance(t *testing.T) {
r := adr0343()
at := time.Date(2026, 10, 13, 14, 5, 0, 0, time.UTC)
a := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance})
if err := a.Check(at); err != nil {
t.Fatal(err)
}
if a.Headline != "Your agents' memory follows you to every machine" {
t.Errorf("the headline is not what it changes for the operator: %q", a.Headline)
}
if err := asks.Glance(a.Explanation); err != nil {
t.Errorf("%v:\n%s", err, a.Explanation)
}
if n := strings.Count(a.Explanation, "\n• "); n != 3 || !strings.HasPrefix(a.Explanation, "What an agent learns") {
t.Errorf("the summary and its %d points:\n%s", n, a.Explanation)
}
for _, c := range r.Clauses {
if strings.Contains(a.Explanation+a.Details, c.Lead()) {
t.Errorf("the ask lists the clause %d: %q", c.N, c.Lead())
}
}
details := strings.Split(a.Details, "\n")
if len(details) != 2 || !strings.Contains(details[0], "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it.") ||
!strings.Contains(details[0], "Fingerprint "+fingerprint(r.Digest())+".") ||
details[1] != "Read its 13 clauses with the knowledge base's get, number 343." {
t.Errorf("the details:\n%s", a.Details)
}
// The ask made before the brief is composed again as it was, so its warrant still holds after the change.
old := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12})
if !strings.Contains(old.Explanation, "Its 13 clause(s):") || old.Headline != "Accept ADR 0343?" || old.Details != "" || old.Digest() == a.Digest() {
t.Errorf("an ask made before the brief is not composed as it was asked:\n%s", old.Explanation)
}
// Without a brief (a record proposed before the rule), the glance is the title, and no clause.
r.Brief = nil
a = composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance})
if a.Headline != "Approve ADR 0343?" || asks.Glance(a.Explanation) != nil || strings.Contains(a.Explanation, "Only the memory folders") {
t.Errorf("without a brief: %q\n%s", a.Headline, a.Explanation)
}
}
// A brief is held to what the phone reads at a glance, each way it fails named.
func TestABriefIsHeldToAGlance(t *testing.T) {
good := adr0343().Brief
if got := briefProblems(good); len(got) != 0 {
t.Fatalf("the brief of ADR 0343 is refused: %v", got)
}
for name, c := range map[string]struct {
change func(b *Brief)
says string
}{
"no headline": {func(b *Brief) { b.Headline = "" }, "headline is empty"},
"long headline": {func(b *Brief) { b.Headline = strings.Repeat("memory ", 10) }, "longer than 60"},
"no summary": {func(b *Brief) { b.Summary = " " }, "summary is empty"},
"long summary": {func(b *Brief) { b.Summary = strings.Repeat("A short sentence. ", 20) }, "summary is"},
"four points": {func(b *Brief) { b.Points = append(b.Points, "one more") }, "4 points"},
"a path": {func(b *Brief) { b.Points[0] = "Carried from the agent's home under .claude/projects" }, "names a path"},
"two lines": {func(b *Brief) { b.Summary = "One.\nTwo." }, "more than one line"},
} {
b := *good
b.Points = append([]string{}, good.Points...)
c.change(&b)
if got := fmt.Sprint(briefProblems(&b)); !strings.Contains(got, c.says) {
t.Errorf("%s: %s", name, got)
}
}
if got := briefProblems(nil); len(got) != 1 || !strings.Contains(got[0], "carries a brief") {
t.Errorf("no brief: %v", got)
}
}
@@ -238,6 +238,8 @@ type ProposeAsk struct {
Links []LinkAsk
Homes []string
NeedsOperator bool
// Brief is what the operator's ask shows (novox/hq issue 639).
Brief *Brief
// Status is a research effort's: active, graduated or abandoned.
Status string
// Insight, for an accepted record, is what stood before: the proposal is a progressive insight.
@@ -280,7 +282,7 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
}
r := &Record{Kind: a.Kind, Number: a.Number, Title: strings.TrimSpace(a.Title), Topic: strings.TrimSpace(a.Topic),
Status: StatusProposed, Date: strings.TrimSpace(a.Date), Deciders: a.Deciders, Effort: a.Status,
NeedsOperator: a.NeedsOperator, Sections: a.Sections, Homes: a.Homes, Author: caller}
NeedsOperator: a.NeedsOperator, Brief: a.Brief, Sections: a.Sections, Homes: a.Homes, Author: caller}
if r.Date == "" {
r.Date = k.now().Format("2006-01-02")
}
@@ -914,8 +914,8 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
Description: "Propose a record whole under a reserved number: its title, topic, date and deciders; for a decision its " +
"clauses, each opening with a bold lead (\"**What is decided.** …\"), and its reasoning in sections (Context, " +
"Considered Options, Consequences, How it is checked, References); for a research effort its status and documents; its " +
"links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator. Refused, naming " +
"every reason at once and writing nothing, when it breaks one of the 29 rules that judge one record or its prose — a " +
"links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator, with the brief its ask shows. Refused, naming " +
"every reason at once and writing nothing, when it breaks one of the 30 rules that judge one record or its prose — a " +
"link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " +
"proposal is revised by proposing it again; an accepted record changes only by a progressive insight (insight: what " +
"stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " +
@@ -937,13 +937,19 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
"to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}},
"homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"),
"needs-operator": flag("it holds only once the operator approves it"),
"status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"},
"insight": text("for an accepted record: a progressive insight, saying what stood before"),
"why": text("one line: why this version"),
"brief": map[string]any{"type": "object", "description": "required with needs-operator: what the operator's phone shows " +
"in place of the clauses (issue 639) — a headline of at most 60 characters saying what it changes for the operator, " +
"not the title; a summary of two or three short sentences, at most 300 characters, on what changes, why and what it " +
"costs or risks; at most three points of at most 160 characters. No path, address, dotted name or long word",
"properties": map[string]any{"headline": text("what it changes for the operator"), "summary": text("two or three short sentences"),
"points": texts("at most three, only where they add something")}, "required": []string{"headline", "summary"}},
"status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"},
"insight": text("for an accepted record: a progressive insight, saying what stood before"),
"why": text("one line: why this version"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("propose", m, "kind", "number", "title", "topic", "date", "deciders", "clauses", "sections",
"links", "homes", "needs-operator", "status", "insight", "why")
"links", "homes", "needs-operator", "brief", "status", "insight", "why")
if err != nil {
return nil, err
}
@@ -977,6 +983,12 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil {
return nil, err
}
if m["brief"] != nil {
p.Brief = &Brief{}
if err := decode(m["brief"], p.Brief, "brief is {headline, summary, points}"); err != nil {
return nil, err
}
}
return k.Propose(caller(m), p)
}},
{Name: Seat + ".import",
@@ -84,9 +84,48 @@ func fingerprint(digest string) string {
return strings.Join(g, " ")
}
// AskGlance is the form of an ask read at a glance (OperatorAsk.Form, novox/hq issue 639).
const AskGlance = 1
// composeAsk is the ask for a record, composed from the record and its ask alone: asked once, and composed
// again to hold the warrant to it.
// again to hold the warrant to it — in the form it was asked in, so an ask made before a form changed is still
// held to the words the operator was shown.
func composeAsk(r *Record, a *OperatorAsk) asks.Ask {
if a.Form == AskGlance {
return glanceAsk(r, a)
}
return listedAsk(r, a)
}
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): the brief's headline, its summary and
// points; who proposed it, its review, its fingerprint and how to read every clause are its Details, which the
// router notes after the answers. A record without a brief (one proposed before the rule) is asked with its
// title as its summary. The clauses are never listed.
func glanceAsk(r *Record, a *OperatorAsk) asks.Ask {
name := r.Key().String()
headline, explanation := "Approve "+name+"?", name+" needs your approval before it holds."
if t := strings.TrimSuffix(strings.TrimSpace(r.Title), "."); shown(t) && len([]rune(t)) < asks.SummaryLength {
explanation = t + "."
}
if r.Brief != nil && len(briefProblems(r.Brief)) == 0 {
headline, explanation = strings.TrimSpace(r.Brief.Headline), r.Brief.Explanation()
}
author := r.Author
if author == "" {
author = "an agent session"
}
clauses := fmt.Sprintf("Read its %d clauses with the knowledge base's get, number %d.", len(r.Clauses), r.Number)
if len(r.Clauses) == 0 {
clauses = fmt.Sprintf("Read it whole with the knowledge base's get, number %d.", r.Number)
}
details := fmt.Sprintf("%s, proposed by %s; the knowledge base's own review approved it. Fingerprint %s.\n%s",
name, author, fingerprint(r.Digest()), clauses)
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: explanation, Details: details})
}
// listedAsk is the ask as it was composed before the brief: every clause's lead listed. Composed only to hold
// the warrant of an ask made then.
func listedAsk(r *Record, a *OperatorAsk) asks.Ask {
name := r.Key().String()
headline := "Accept " + name + "?"
var b strings.Builder
@@ -111,9 +150,15 @@ func composeAsk(r *Record, a *OperatorAsk) asks.Ask {
}
}
fmt.Fprintf(&b, "\n\nRead it whole with the knowledge base's get, number %d; its fingerprint is %s.", r.Number, fingerprint(r.Digest()))
out := asks.Ask{ID: a.ID, Headline: headline, Explanation: b.String(), Who: asks.Operator,
Expires: a.At.Add(AskLasts), OnExpiry: "the record stays proposed; nothing of it is in force",
About: "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)}
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: b.String()})
}
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its
// two options, each binding its act.
func withOptions(r *Record, a *OperatorAsk, out asks.Ask) asks.Ask {
out.Who, out.Expires = asks.Operator, a.At.Add(AskLasts)
out.OnExpiry = "the record stays proposed; nothing of it is in force"
out.About = "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "the record is accepted and its clauses are in force"},
{OptionDecline, "Decline", "the record stays proposed; nothing of it is in force"},
@@ -157,7 +202,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
if k.Asks == nil {
return unasked("the operator cannot be asked from here")
}
oa := &OperatorAsk{ID: newAskID(w), At: k.now(), Version: version, Said: AskWaiting}
oa := &OperatorAsk{ID: newAskID(w), At: k.now(), Version: version, Said: AskWaiting, Form: AskGlance}
ask := composeAsk(w, oa)
if err := ask.Check(k.now()); err != nil {
return unasked(fmt.Sprintf("the ask could not be made (%v)", err))
@@ -248,6 +248,9 @@ type OperatorAsk struct {
ID string `json:"id"`
At time.Time `json:"at"`
Version int `json:"version"`
// Form is how the ask was composed, so it is composed again the same way to hold the warrant to it: 0 for
// an ask made before the brief (the clause leads listed), AskGlance for one read at a glance (issue 639).
Form int `json:"form,omitempty"`
// Said is what came of it: waiting, approved, declined, expired, refused (and why).
Said string `json:"said"`
Warrant string `json:"warrant,omitempty"`
@@ -291,11 +294,14 @@ type Record struct {
// written; what is one is a touches link.
Touches []string `json:"touches,omitempty"`
// Effort is the status a proposed research effort takes when it is accepted.
Effort string `json:"effort,omitempty"`
NeedsOperator bool `json:"needs-operator,omitempty"`
Clauses []Clause `json:"clauses,omitempty"`
Links []Link `json:"links,omitempty"`
Sections []Section `json:"sections,omitempty"`
Effort string `json:"effort,omitempty"`
NeedsOperator bool `json:"needs-operator,omitempty"`
// Brief is what the operator's phone shows when the record asks for their Approve: written by its author,
// in the operator's words (novox/hq issue 639). Required of a record that needs the operator (the rule brief).
Brief *Brief `json:"brief,omitempty"`
Clauses []Clause `json:"clauses,omitempty"`
Links []Link `json:"links,omitempty"`
Sections []Section `json:"sections,omitempty"`
// Homes are the documents of hq that cite the record (a design, a playbook), while those live in hq.
Homes []string `json:"homes,omitempty"`
// Extra holds the fields of an imported record this slice has no field of its own for, as written.
@@ -329,6 +335,25 @@ type Insight struct {
By string `json:"by,omitempty"`
}
// Brief is a record as the operator is asked about it, on a phone, at a glance (novox/hq issue 639): a headline
// that says what the record changes for the operator — not its title — a summary of two or three short
// sentences on what changes, why, and what it costs or risks, and at most three points, only where they add
// something. The clauses are never listed in the ask; it says how to read them.
type Brief struct {
Headline string `json:"headline"`
Summary string `json:"summary"`
Points []string `json:"points,omitempty"`
}
// Explanation is the brief as the ask explains it: the summary, then a line for each point.
func (b Brief) Explanation() string {
out := strings.TrimSpace(b.Summary)
for _, p := range b.Points {
out += "\n• " + strings.TrimSpace(p)
}
return out
}
// PendingState marks a version that is a proposal over an accepted record: a progressive insight waiting for
// its review. The accepted version stays the one served until it is accepted.
type PendingState struct {
@@ -442,6 +467,12 @@ func Render(r *Record) string {
if r.NeedsOperator {
field("needs operator", "yes")
}
if r.Brief != nil {
field("brief", r.Brief.Headline)
for _, l := range strings.Split(r.Brief.Explanation(), "\n") {
field("brief", l)
}
}
field("homes", strings.Join(r.Homes, ", "))
keys := make([]string, 0, len(r.Extra))
for k := range r.Extra {
@@ -18,6 +18,10 @@ func proposed(t *testing.T, w *world, author string, needsOperator bool) int {
n := w.reserved(t, author)
p := valid(n)
p.NeedsOperator = needsOperator
if needsOperator {
p.Brief = &Brief{Headline: "Merges land after ninety seconds of quiet",
Summary: "A merge window closes after ninety seconds without a merge, so merges land sooner."}
}
if _, err := w.k.Propose(author, p); err != nil {
t.Fatal(err)
}
@@ -245,7 +249,8 @@ func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) {
}
ask := w.router.asked[0]
if err := ask.Check(w.clock); err != nil || ask.Options[0].Level != asks.Approve || ask.Options[1].Level != asks.Approve ||
!strings.Contains(ask.Explanation, "the knowledge base's own review") {
!strings.Contains(ask.Details, "the knowledge base's own review") || ask.Headline != "Merges land after ninety seconds of quiet" ||
asks.Glance(ask.Explanation) != nil || strings.Contains(ask.Explanation, "The window closes") {
t.Fatalf("the ask: %v %+v", err, ask)
}
// Without the warrant: nothing is accepted, however often the router's record is read or the reviews run.
@@ -2,9 +2,10 @@ package main
// The rules a record is held to (novox/hq ADR 0290 rule 4, to-be 55 §4 and §5).
//
// **29 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every
// **30 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every
// one it breaks at once. They are hq's checks (00-META/checks/records.py, cycle.py, index.py, words.py) as they
// judge one record, moved to the write: 25 on its structure, 4 on its prose. Each has an id, said in the refusal,
// judge one record, moved to the write: 25 on its structure, 4 on its prose; and the brief a record that needs
// the operator is asked with (novox/hq issue 639). Each has an id, said in the refusal,
// and one case in the table test (TestEveryPerRecordRuleRefusesAProposal).
//
// **4 rules judge the whole record set** (wholeSetRules) and run after every acceptance, and in the check
@@ -15,6 +16,8 @@ import (
"fmt"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Rule is one rule that judges one record: its id and what it holds.
@@ -25,7 +28,7 @@ type Rule struct {
From string
}
// PerRecordRules are the 29, in the order they are judged.
// PerRecordRules are the 30, in the order they are judged.
var PerRecordRules = []Rule{
{"kind", "a record is a decision or a research effort", "cycle.py"},
{"reserved", "its number was reserved for its kind and holds no other record", "cycle.py (duplicate numbers)"},
@@ -56,6 +59,7 @@ var PerRecordRules = []Rule{
{"daemon-line", "it writes no daemon's line", "words.py lines"},
{"deferral", "a paragraph that puts work off names the issue that tracks it", "cycle.py deferrals"},
{"insight-form", "a progressive insight its text mentions is in the dated, marked form", "records.py insights"},
{"brief", "a record that needs the operator carries a brief its ask reads at a glance: a headline, a summary, at most three points, each fit for the phone", "novox/hq issue 639"},
}
// Refusal is one rule a record breaks, and how.
@@ -116,7 +120,7 @@ func allText(r *Record) string {
return b.String()
}
// JudgeRecord holds a proposed record to the 29 rules. held is the entry its number holds (a reservation, the
// JudgeRecord holds a proposed record to the 30 rules. held is the entry its number holds (a reservation, the
// proposal being revised, or the accepted record an insight corrects); lookup reads every other record.
func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal {
var out []Refusal
@@ -349,6 +353,11 @@ func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusa
refuse("insight-form", "a progressive insight dated %s predates the record (%s)", mk[1], r.Date)
}
}
if r.NeedsOperator || r.Brief != nil {
for _, why := range briefProblems(r.Brief) {
refuse("brief", "%s", why)
}
}
return out
}
@@ -597,6 +606,7 @@ var RuleFrom = map[string]string{
"consequences": "2026-10-11",
"checked": "2026-10-11",
"live-citation": "2026-10-11",
"brief": "2026-10-13",
}
// DecisionFrom overrides RuleFrom for a decision record: hq's words check never read 02-DECISIONS ("a decision
@@ -615,3 +625,35 @@ func Holds(rule, kind, date string) bool {
}
return !dated || !isDay.MatchString(date) || date >= from
}
// briefProblems are the ways a brief would not read at a glance on the operator's phone (novox/hq issue 639):
// missing; a headline empty or longer than an ask's; a summary or a point the router could refuse, or that
// names a path, an address or a long word (shown); more than three points, or one too long (asks.Glance).
func briefProblems(b *Brief) []string {
if b == nil {
return []string{"a record that needs the operator carries a brief (headline, summary, points): what it changes for the operator, " +
"in their words, which its ask shows in place of its clauses"}
}
var out []string
h := strings.TrimSpace(b.Headline)
if h == "" || strings.Contains(h, "\n") || len([]rune(h)) > asks.HeadlineLength {
out = append(out, fmt.Sprintf("the brief's headline is empty, more than one line, or longer than %d characters", asks.HeadlineLength))
}
if s := strings.TrimSpace(b.Summary); s == "" || strings.Contains(s, "\n") {
out = append(out, "the brief's summary is empty or more than one line")
}
for i, p := range b.Points {
if p = strings.TrimSpace(p); p == "" || strings.Contains(p, "\n") {
out = append(out, fmt.Sprintf("the brief's point %d is empty or more than one line", i+1))
}
}
if err := asks.Glance(b.Explanation()); err != nil {
out = append(out, "the brief "+strings.TrimPrefix(err.Error(), "the explanation "))
}
for _, line := range append([]string{h}, strings.Split(b.Explanation(), "\n")...) {
if !shown(line) {
out = append(out, fmt.Sprintf("the brief's line %q names a path, an address, a dotted name or a long word, which the operator's phone is not shown", line))
}
}
return out
}
@@ -90,6 +90,9 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) {
{"insight-form", func(p *ProposeAsk, w *world, t *testing.T) {
p.Sections[2].Text = "A progressive insight corrected the count."
}},
{"brief", func(p *ProposeAsk, w *world, t *testing.T) {
p.Date, p.NeedsOperator = "2026-10-13", true
}},
}
covered := map[string]bool{}
for _, c := range cases {
@@ -100,8 +103,8 @@ func TestEveryPerRecordRuleRefusesAProposal(t *testing.T) {
t.Errorf("the rule %s has no case", r.ID)
}
}
if len(PerRecordRules) != 29 {
t.Errorf("%d per-record rules; ADR 0290 counts 29", len(PerRecordRules))
if len(PerRecordRules) != 30 {
t.Errorf("%d per-record rules; ADR 0290 counts 29, and issue 639 adds the brief", len(PerRecordRules))
}
for _, c := range cases {
t.Run(c.rule, func(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-knowledge
go 1.25.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
github.com/jackc/pgx/v5 v5.11.0
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.51.0
+6
View File
@@ -1,5 +1,11 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf h1:ED/+RszKwm86U7H/NZRbS4b5LzQ8n7rubqKmeFqFtvM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011091942-4cf45b5ca0cf/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+164
View File
@@ -0,0 +1,164 @@
# mesh-vault
Holds the `mesh-vault` seat: the mesh's shared secrets, each kept sealed, handed out only on request and
reply, sealed to whoever asked (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11). Go, one binary
(`cmd/mesh-vault`), launched by its node's runtime and speaking MCP to it through the Go SDK.
## Where the secrets are kept, and why there
In the vault's own store: one file per secret in `${dir:store}` on the vault's own disk, mode 0600 in a
0700 directory. Not a database, as most Go modules keep their data, because:
- the vault is installed before any other module built on the runtime base (ADR 0113 §14), and a database
login is itself a shared secret the vault makes — the vault cannot need one to start;
- every value in the store is ciphertext anyway, so a database would add nothing a file does not;
- no secret is in a bucket, sealed or not (ADR 0201 §13), so the module declares no `state`.
Each record holds the value twice, never in the clear:
- **at rest**, sealed to the vault's own key (the sealed box of ADR 0206, `mesh-sdk/go/seal`). The key is
an X25519 pair the vault makes once in `${dir:state}/vault-key.json`; its private half never leaves it.
The key is made only for an empty store: with the key file missing and secrets in the store, the vault
does not start and says so, rather than make a key that opens none of them.
- **for the operator**, sealed to the operator key (ADR 0113 §22), so nothing exists only inside the
vault. A secret is refused, loudly, while the vault has no operator key.
Every file is written whole, synced, renamed into place and its directory synced.
### What the encryption at rest protects against, and what not
The vault's private key lives in plaintext in `${dir:state}`, on the same disk as the store, readable by the
vault's account alone. So:
- **It protects** against a copy of the store directory alone — a `store` backup, a file sent somewhere,
a listing of `${dir:store}` by a tool that reads files — holding anything usable, and against a value
being found by searching the disk for it.
- **It does not protect** against anyone who can read the vault's state directory too: root on the
vault's node, the vault's own account, or a backup that holds both `state` and `store` (both are
class `valuable`, so the node's backup holds both). Against those, at-rest encryption adds nothing; the
protection is the node's own security. Keeping the key elsewhere (another directory class, a key the
operator unlocks) is for V3's review, before real secrets move in.
## Where the operator key comes from
From the export the mesh writes to the vault's root directory (`${dir:root}/export.json`, the manifest's
`keeps`): its `operator-key` is the public half `operator key set` recorded in the controller. The vault
checks it every minute; a new key makes the vault seal every operator copy again to it — it can, because
it opens its own copy. Each record names the key its copy is sealed to, so a failure partway is finished on
the next minute's pass, and nothing is left sealed to an earlier key for good.
The vault takes an operator key only as SPKI PEM (`SetOperatorKey`). A PEM private key is refused, and so
is a raw 32-byte key, because a raw private key looks the same. The one raw key it reads is the export's,
which the controller writes from what it recorded; the vault wraps it as SPKI before taking it. It keeps
the key raw in base64, the form of the operator's NaCl box. When the secrets move to the vault (design 57's
V3), the operator key moves with them.
### How the operator opens a copy
`mesh-vault.secret_vault_export` answers the vault's copies in the shape `secret export` writes. Saved to a
file, one is opened with the operator's private key by
nox secret recover vault mesh-vault <name> --key <operator key file> --from-export <file> --out <file>
(`--out` is needed: a name holds `/`). `TestTheOperatorRecoversAVaultSecretFromItsExport` reads the file
as that command does and opens it as it does. `secret_export` still answers the controller's export, as
before; until V3 the controller's secrets are there.
## The boxes and the key formats — settled
Two boxes on the mesh today, both over X25519:
| | format | keys | who uses it |
|---|---|---|---|
| NaCl anonymous box | XSalsa20-Poly1305, `box.SealAnonymous` | raw 32 bytes, base64 | the controller sealing into declarations; the node-engine's `sealing.key` (mesh-host `internal/identity/sealing.go`); the operator key and `secret recover --from-export` |
| the sealed box (ADR 0206) | X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}` | PEM: SPKI public, PKCS #8 private | the licence manager and `claude-code`; `mesh-sdk/go/seal` |
What the vault uses:
- **Its answers to `get` are the ADR 0206 box, for every asker, a node included.** The asker sends its
public key as SPKI PEM. A node's provisioner client (V4) needs no second key: its node's sealing key is
X25519 already, so it sends that key's public half wrapped as SPKI and opens the answer with the private
half wrapped as PKCS #8 (`TestANodesSealingKeyOpensTheAnswer` does exactly this). A raw 32-byte key is
refused: a raw private key looks the same, and the vault takes no private key.
- **Its operator copies are the NaCl anonymous box**, to the operator key as it is made today, so the
operator's recovery opens them with the key and the command it already has.
- **The at-rest copy is the ADR 0206 box**, to the vault's own key.
The NaCl box stays on the node-engine only for what the controller seals into declarations, until the last
module has moved (ADR 0349 §17). For the core's own credentials that travel in a declaration sealed by the
vault (ADR 0349 §16), V4 adds opening the ADR 0206 box with the same sealing key to the node-engine.
## The seat's verb
`mesh-vault.get` — `name`, `public_key` (SPKI PEM). It answers `{name, generation, changed-at, sealed}`,
`sealed` being the secret sealed to that key, and only to an asker allowed for that secret, the asker being
the bus user the runtime proved — never an argument.
Every answer and every refusal is recorded in the vault's own store (`handed.jsonl`: who, which secret,
which generation, when, and why a refusal), synced before the answer is given — as ADR 0277 §25 records
asks; a secret whose hand-out cannot be recorded is not handed out. A refusal is recorded only for a caller
of a bus user's shape and a well-formed secret name, so an arbitrary argument fills nothing. The record is
bounded: rolled at 1 MiB into `handed.jsonl.1` … `.3`, the oldest dropped, each roll said loudly in the
journal and counted in `vault_status`. `secret_handed` reads it.
`vault_status` says what needs a person: operator copies still sealed to an earlier key, an operator key in
the export the vault refused, and how often the record was rolled. **It is not yet a condition**: a health
check is declared on a long-running resource (ADR 0240), and the vault is a bundle the tool runner loads, so
nothing asks it on its own; until it runs as a resource of its own (V3's review) these reach a person
through the journal and this tool only.
**Who may ask, today: nobody.** The rule (ADR 0349 §11) is a node's provisioner client under a bus account
of its own, for what its node's declaration names, and a module under its own account (issue 569). Neither
exists yet, so the running vault refuses every asker; the tests set a stand-in list. The real rule goes
behind the `Askers` interface in `askers.go` (design 57's V4 and P3).
## Events
- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value
or a sealed copy.
- `provisioned`, `rotated` — the custody ledger's, by fingerprint, as before.
- `provisioner.retirement` (ADR 0230 §35) — `waiting`, `settled`, `approved`, `rejected`, `retired`,
`reenabled`, `deleted`, naming holders, never a value.
## The tools
`secret_export`, `secret_holders`, `secret_verify` answer as the TypeScript vault's did, and its custody
ledger (`${dir:ledger}`, the same JSON files) is kept by a pass over the contributions file every minute.
`secret_handed` and `secret_vault_export` read the vault's own store. None returns a value.
**The ledger follows ADR 0230.** A contributions file that does not say who is asked for (unreadable, not
JSON, no `given` list or a null one, or for another requirement) is not "nobody": the pass records nothing,
retires nobody and starts its count again. A holder no longer asked for is retired — a mark with when and
why in its own ledger file, its history kept — only after the same set has gone unasked in five passes and
ten minutes; more than three at once, or more than half of those held, waits for a person
(`provisioner_retire_approve`, `provisioner_retire_reject`); asked for again it is re-enabled; it is deleted
only by `provisioner_delete`, a person's act with a why. `provisioner_retirement` shows it all. The vault is
mark-only: it holds no access to switch off.
The words are the controller's: the `provisioner.retirement` event is mesh-controller's `link.Retirement`
(`provider-node`, `consumers` as objects) and the `provisioner_retirement` answer its `link.RetirementState`
(`held`, `bound`, `retires_by: mark-only`), so `retire approve`, `retire reject` and `cleanup list` read the
vault and a set waiting for a person raises the controller's urgent condition. The acts take what the SDK's
harness takes: approve and reject repeat the set in `consumers` and refuse any other set; delete repeats the
name in `confirm`; each records the `by` and `via` the controller sends. `testdata/retirement-*.json` are
those types marshalled by the controller (feat/57 at dc36536), and the tests hold the vault's words to them.
What the TypeScript vault did and this one does not: it consumed its own events only to print an audit line,
which the pass now prints itself. Its retirement ran on the SDK's harness, which this one follows: mark-only,
and a holder's ledger file removed only through a person's delete, as here. The difference is where the mark
lives: the harness kept it in its process, so a restart forgot it (ADR 0230 §13); here it is in the ledger
file.
## What comes later
Taking values in (`secret accept`, `secret-ask`, `issued`, `rotate`) is V2; the controller's secrets move
in with V3; nodes ask with V4; pair credentials are minted here with V5. Until V3 the vault's store is
empty and the controller still serves every secret.
## Tests
go test ./...
`vault_test.go` is ADR 0349's vault test list as far as this package goes: no bucket holds a secret; no
private key is accepted; every secret has an operator-sealed copy; `get` answers only an allowed asker,
sealed to its key; `secret-changed` carries no value.
-172
View File
@@ -1,172 +0,0 @@
// mesh-vault's ledger — vault's own code, living in the module (novox/hq ADR 0039). The provisioner and
// the tools both import it, and nothing outside vault does.
//
// **The vault holds no value.** A `secret` is an ordinary pair credential: the controller mints it,
// seals it to the consumer's node and to this one, and the host unseals this node's copy into the
// file the contribution names (ADR 0048). That file is already on this machine, readable by nothing
// but the vault's runtime, and it is the only copy the vault ever sees. Writing a second copy —
// plain, or sealed to a key the vault keeps — would put back exactly the single place that can open
// everything, which is what sealing to the machine was built to remove (ADR 0085's open question is
// how to recover WITHOUT that; the answer is not "keep one anyway").
//
// So what the vault keeps is what makes a secret *owned* rather than merely delivered: who holds
// one, since when, its fingerprint, and every time it changed. Enough to say "this holder's value is
// the one the mesh last delivered" and "it has been rotated twice, last on Tuesday" — and never
// enough to say what it is. The fingerprint is the only thing a tool may take or return, which is
// the rule the source mesh's secret tools were built on: the secret is never an argument.
import { createHash } from "node:crypto";
import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** One holder of a secret this vault provides — everything the vault knows, and no value. */
export interface Held {
/** The login the mesh derived for the consumer — `<node>-<module>`, so it names the holder. */
readonly as: string;
/** The consumer's node. */
readonly consumer: string;
/** sha256 of the value the mesh last delivered, `sha256:<hex>`. Compared, never inverted. */
readonly fingerprint: string;
/** Length of the value, so a holder can tell a truncated file from a wrong one. */
readonly length: number;
/** When this holder was first granted a secret. */
readonly since: string;
/** When the value last changed — equal to `since` until the first rotation. */
readonly changed: string;
/** How many times the value has changed since `since`. */
readonly rotations: number;
/** Every earlier fingerprint, oldest first: the audit trail a rotation leaves. */
readonly history: readonly { readonly fingerprint: string; readonly until: string }[];
}
/** What recording a delivery found: a new holder, a changed value, or nothing new. */
export type Outcome = "granted" | "rotated" | "unchanged";
/** sha256 of a value, as `sha256:<hex>`. The one thing about a secret that may be spoken. */
export function fingerprint(value: string): string {
return "sha256:" + createHash("sha256").update(value, "utf8").digest("hex");
}
export class Ledger {
private readonly dir: string;
constructor(dir: string) {
this.dir = dir;
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
/** Build from the module's resolved environment: $MESH_VAULT_LEDGER is where holders are kept. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): Ledger {
const dir = env.MESH_VAULT_LEDGER;
if (!dir) {
throw new Error("MESH_VAULT_LEDGER is not set — the vault has nowhere to keep its ledger");
}
return new Ledger(dir);
}
/**
* Record that the mesh delivered `value` for `as`. Idempotent: the same value again changes
* nothing, a different value is a rotation and is remembered as one. The value is fingerprinted
* here and goes no further.
*/
record(as: string, consumer: string, value: string, now = new Date()): { held: Held; outcome: Outcome } {
const fp = fingerprint(value);
const at = now.toISOString();
const before = this.get(as);
if (!before) {
const held: Held = {
as, consumer, fingerprint: fp, length: value.length,
since: at, changed: at, rotations: 0, history: [],
};
this.write(held);
return { held, outcome: "granted" };
}
if (before.fingerprint === fp && before.length === value.length) {
return { held: before, outcome: "unchanged" };
}
const held: Held = {
...before, consumer, fingerprint: fp, length: value.length, changed: at,
rotations: before.rotations + 1,
history: [...before.history, { fingerprint: before.fingerprint, until: at }],
};
this.write(held);
return { held, outcome: "rotated" };
}
/** Forget a holder the mesh withdrew. Returns whether there was one to forget. */
withdraw(as: string): boolean {
try {
unlinkSync(this.pathOf(as));
return true;
} catch {
return false;
}
}
get(as: string): Held | undefined {
try {
return JSON.parse(readFileSync(this.pathOf(as), "utf8")) as Held;
} catch {
return undefined;
}
}
/** Every holder, by login. */
list(): Held[] {
let names: string[];
try {
names = readdirSync(this.dir);
} catch {
return [];
}
return names
.filter((n) => n.endsWith(".json"))
.map((n) => this.get(n.slice(0, -".json".length)))
.filter((h): h is Held => h !== undefined)
.sort((a, b) => a.as.localeCompare(b.as));
}
private pathOf(as: string): string {
if (!/^[a-z0-9][a-z0-9_.-]*$/.test(as)) {
throw new Error(`a login is a name, not a path: ${JSON.stringify(as)}`);
}
return join(this.dir, `${as}.json`);
}
/** Written whole and renamed into place, so a reader never sees half a record. */
private write(held: Held): void {
const final = this.pathOf(held.as);
const tmp = `${final}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(held, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, final);
}
}
/** One entry of the mesh's contributions file, as the vault reads it for its tools. */
export interface Contribution {
readonly from?: string;
readonly node?: string;
readonly as: string;
readonly secret: string;
}
/** The consumers the mesh currently asks this vault to serve — the `receives` file, read plainly. */
export function contributions(receives: string): Contribution[] {
let doc: { given?: Contribution[] };
try {
doc = JSON.parse(readFileSync(receives, "utf8")) as { given?: Contribution[] };
} catch {
return [];
}
return (doc.given ?? []).filter((g) => g.as && g.secret);
}
/** Fingerprint of the value the host currently holds for one contribution, or why it could not. */
export function deliveredFingerprint(c: Contribution): { fingerprint: string; length: number } | { error: string } {
try {
const value = readFileSync(c.secret, "utf8").replace(/\n$/, "");
return { fingerprint: fingerprint(value), length: value.length };
} catch (err) {
return { error: `the delivered secret is not readable: ${err}` };
}
}
@@ -0,0 +1,37 @@
package main
// Who may ask `get` for which secret: the seam the real rule fills (novox/hq ADR 0349 §11).
//
// The rule is: a node's provisioner client, running as a service under a bus account of its own, for a
// secret that node's declaration names; or a module, for a secret its manifest declares, only once its
// code runs under a bus account of its own (issue 569). Neither exists yet — the client and its account are
// design 57's P3, the vault's answer to a node V4 — so the vault that runs allows nobody (NoAsker), and the
// tests set a stand-in list (AllowList). V4 and P3 put the real rule behind this interface.
// Askers decides whether caller (the bus user the runtime proved) may have the secret name, and if not,
// why not.
type Askers interface {
May(caller, name string) (bool, string)
}
// NoAsker allows nobody: the vault's rule until who may ask is decided by a declaration and an account.
type NoAsker struct{}
// May refuses every caller.
func (NoAsker) May(string, string) (bool, string) {
return false, "the vault hands a secret to no asker yet: a node's provisioner client under its own account, " +
"for what its declaration names, is the first it will answer (novox/hq ADR 0349 §11, issue 647)"
}
// AllowList is a stand-in rule: each caller, and the names it may have.
type AllowList map[string][]string
// May allows a caller only the names listed for it.
func (l AllowList) May(caller, name string) (bool, string) {
for _, n := range l[caller] {
if n == name {
return true, ""
}
}
return false, "not on the list of who may ask for it"
}
+168
View File
@@ -0,0 +1,168 @@
package main
// The keys the vault meets, and the one it holds (novox/hq ADR 0113 §2, ADR 0206).
//
// - Its own key: an X25519 pair made once in its state directory, the private half never leaving it. Every
// secret is kept sealed to it at rest.
// - An asker's key: the public half only, as SPKI PEM — the form of the ADR 0206 box (mesh-sdk go/seal).
// A raw 32-byte key is refused, because a raw private key looks the same and the vault takes none.
// - The operator key: the public half, taken as SPKI PEM only and kept raw in base64, the form `operator key
// make` writes and the NaCl box the operator opens uses.
//
// No private key is ever accepted: not as a key to seal to, not as a secret to hold.
import (
"crypto/ecdh"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/nacl/box"
)
// OwnKeyFile is where the vault's own key lives in its state directory.
const OwnKeyFile = "vault-key.json"
// OwnKey is the vault's own key pair, made once in dir. Written whole and synced, then linked into place and
// the directory synced, so a second process reads the first's key and never half of it.
//
// **A key is made only for an empty store.** When the key file is missing and the store holds secrets, the
// key they are sealed to is lost or misplaced, and a new key would open none of them: the vault refuses to
// start, loudly, rather than seal new secrets to a key beside old ones nothing opens. The operator copies
// are what re-raise it (ADR 0113 §21).
func OwnKey(dir string, store *Store) (seal.KeyPair, error) {
if dir == "" {
return seal.KeyPair{}, errors.New("the vault has no state directory: MESH_VAULT_STATE is not set")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return seal.KeyPair{}, err
}
file := filepath.Join(dir, OwnKeyFile)
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
if store != nil {
empty, err := store.Empty()
if err != nil {
return seal.KeyPair{}, fmt.Errorf("the vault's key is missing and its store cannot be read: %w", err)
}
if !empty {
return seal.KeyPair{}, fmt.Errorf("THE VAULT'S KEY IS MISSING (%s) WHILE ITS STORE HOLDS SECRETS SEALED TO IT: "+
"the vault does not start and makes no new key, which would open none of them — put the key back, "+
"or re-raise the vault from the operator copies (novox/hq ADR 0113 §21)", file)
}
}
k, err := seal.GenerateKeyPair()
if err != nil {
return seal.KeyPair{}, err
}
raw, _ := json.Marshal(k)
tmp := filepath.Join(dir, fmt.Sprintf(".vault-key.%d.json", os.Getpid()))
if err := writeSynced(tmp, raw); err != nil {
return seal.KeyPair{}, err
}
_ = os.Link(tmp, file) // fails when another made it first, which is right
_ = os.Remove(tmp)
if err := syncDir(dir); err != nil {
return seal.KeyPair{}, err
}
}
raw, err := os.ReadFile(file)
if err != nil {
return seal.KeyPair{}, err
}
var k seal.KeyPair
if err := json.Unmarshal(raw, &k); err != nil || k.PublicKey == "" || k.PrivateKey == "" {
return seal.KeyPair{}, fmt.Errorf("%s is not the vault's key", file)
}
return k, nil
}
var privateKeyPEM = regexp.MustCompile(`-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----`)
// privateKeyIn says whether text carries a private key.
func privateKeyIn(text string) bool {
return privateKeyPEM.MatchString(text) || strings.Contains(text, "PRIVATE KEY")
}
// askerKey is an asker's public key, checked and as PEM: SPKI, X25519, and never a private key.
func askerKey(key string) (string, error) {
if privateKeyIn(key) {
return "", errors.New("that is a private key, and a private key is never sent to the vault (ADR 0113 §2): send the public half")
}
block, rest := pem.Decode([]byte(strings.TrimSpace(key)))
if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" {
return "", errors.New("the key to seal to is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`), the form of the sealed box (ADR 0206)")
}
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("the key to seal to is not a public key: %w", err)
}
pub, ok := k.(*ecdh.PublicKey)
if !ok || pub.Curve() != ecdh.X25519() {
return "", errors.New("the key to seal to is not an X25519 key")
}
return string(pem.EncodeToMemory(block)), nil
}
// checkOperatorKey takes the operator's public key as SPKI PEM — never a private key, and never a raw key,
// which cannot be told from a private one — and gives it in the raw base64 form the operator's NaCl box and
// `secret recover` use.
func checkOperatorKey(key string) (string, error) {
if privateKeyIn(key) {
return "", errors.New("that is a private key: the operator key's private half never enters the mesh (ADR 0113 §2)")
}
block, rest := pem.Decode([]byte(strings.TrimSpace(key)))
if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" {
return "", errors.New("the operator key is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`); " +
"a raw key is refused, because a raw private key looks the same")
}
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("the operator key is not a public key: %w", err)
}
pub, ok := k.(*ecdh.PublicKey)
if !ok || pub.Curve() != ecdh.X25519() {
return "", errors.New("the operator key is not an X25519 key")
}
return base64.StdEncoding.EncodeToString(pub.Bytes()), nil
}
// publicPEM wraps a raw X25519 public key, base64, as SPKI PEM.
func publicPEM(raw string) (string, error) {
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil || len(b) != 32 {
return "", errors.New("not a raw X25519 key in base64")
}
pub, err := ecdh.X25519().NewPublicKey(b)
if err != nil {
return "", err
}
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", err
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil
}
// operatorSeal seals value to the operator key in the box the operator opens today.
func operatorSeal(key string, value []byte) (string, error) {
raw, err := base64.StdEncoding.DecodeString(key)
if err != nil || len(raw) != 32 {
return "", errors.New("the operator key is not usable")
}
var pub [32]byte
copy(pub[:], raw)
sealed, err := box.SealAnonymous(nil, value, &pub, rand.Reader)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sealed), nil
}
+251
View File
@@ -0,0 +1,251 @@
package main
// The custody ledger (novox/hq ADR 0085, design 24), carried over from the TypeScript vault: for each pair
// credential the controller delivers to this vault as the `secret` provider, who holds it, since when, its
// fingerprint and every rotation — and never the value. The controller still mints and seals those until
// the secrets move to the vault (design 57's V3, V4); the pass records what it delivered, so
// `secret_holders` and `secret_verify` answer as before. Its files are the same JSON, read as they are.
//
// **A holder the mesh stops asking for is retired, never deleted by a pass** (novox/hq ADR 0230). The vault
// is a mark-only provider: it has no access to switch off, so retiring writes the mark — when and why — into
// the holder's own ledger file, which keeps its rotation history. A holder is retired only once the same set
// has gone unasked in five consecutive passes that read the contributions file and for ten minutes; a set
// over the bound (more than three, or more than half of those held) waits for a person; asked for again, a
// retired holder is re-enabled at once; a holder is deleted only by `provisioner_delete`, a person's act
// with a why. Because the mark is in the ledger file, a restart forgets nothing.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// isoMillis is JavaScript's toISOString, the form the ledger's times were first written in.
const isoMillis = "2006-01-02T15:04:05.000Z"
// Held is one holder of a secret this vault provides: everything the vault knows of it, and no value.
type Held struct {
As string `json:"as"`
Consumer string `json:"consumer"`
Fingerprint string `json:"fingerprint"`
Length int `json:"length"`
Since string `json:"since"`
Changed string `json:"changed"`
Rotations int `json:"rotations"`
History []Earlier `json:"history"`
// Retired is the mark a retirement writes; nil while the holder is active.
Retired *RetiredMark `json:"retired,omitempty"`
}
// RetiredMark is when and why a holder was retired.
type RetiredMark struct {
At string `json:"at"`
Why string `json:"why"`
}
// Earlier is a fingerprint a rotation replaced, and when.
type Earlier struct {
Fingerprint string `json:"fingerprint"`
Until string `json:"until"`
}
// Outcome is what recording a delivery found.
type Outcome string
const (
Granted Outcome = "granted"
Rotated Outcome = "rotated"
Unchanged Outcome = "unchanged"
)
// Fingerprint is sha256 of a value, as `sha256:<hex>`: the one thing about a secret that may be said.
func Fingerprint(value string) string {
sum := sha256.Sum256([]byte(value))
return "sha256:" + hex.EncodeToString(sum[:])
}
// Ledger is the directory of holders.
type Ledger struct{ dir string }
// NewLedger opens (and makes) the ledger at dir.
func NewLedger(dir string) (*Ledger, error) {
if dir == "" {
return nil, errors.New("MESH_VAULT_LEDGER is not set: the vault has nowhere to keep its ledger")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
return &Ledger{dir: dir}, nil
}
var login = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
func (l *Ledger) pathOf(as string) (string, error) {
if !login.MatchString(as) || strings.Contains(as, "..") {
return "", fmt.Errorf("a login is a name, not a path: %q", as)
}
return filepath.Join(l.dir, as+".json"), nil
}
// Record says the mesh delivered value for as: the same value again changes nothing, another is a rotation.
// The value is fingerprinted here and goes no further. A retirement mark is left as it is.
func (l *Ledger) Record(as, consumer, value string, now time.Time) (Held, Outcome, error) {
path, err := l.pathOf(as)
if err != nil {
return Held{}, "", err
}
fp, at := Fingerprint(value), now.UTC().Format(isoMillis)
before, ok := l.Get(as)
if !ok {
h := Held{As: as, Consumer: consumer, Fingerprint: fp, Length: len(value), Since: at, Changed: at, History: []Earlier{}}
return h, Granted, l.write(path, h)
}
if before.Fingerprint == fp && before.Length == len(value) {
return before, Unchanged, nil
}
h := before
h.Consumer, h.Fingerprint, h.Length, h.Changed = consumer, fp, len(value), at
h.Rotations++
h.History = append(append([]Earlier{}, before.History...), Earlier{Fingerprint: before.Fingerprint, Until: at})
return h, Rotated, l.write(path, h)
}
// Mark writes or clears a holder's retirement mark.
func (l *Ledger) Mark(as string, mark *RetiredMark) error {
path, err := l.pathOf(as)
if err != nil {
return err
}
h, ok := l.Get(as)
if !ok {
return fmt.Errorf("the ledger holds nothing for %s", as)
}
h.Retired = mark
return l.write(path, h)
}
// Delete forgets a holder, saying whether there was one. Only a person's `provisioner_delete` reaches it.
func (l *Ledger) Delete(as string) bool {
path, err := l.pathOf(as)
if err != nil {
return false
}
if os.Remove(path) != nil {
return false
}
_ = syncDir(l.dir)
return true
}
// Get is one holder.
func (l *Ledger) Get(as string) (Held, bool) {
path, err := l.pathOf(as)
if err != nil {
return Held{}, false
}
raw, err := os.ReadFile(path)
if err != nil {
return Held{}, false
}
var h Held
if json.Unmarshal(raw, &h) != nil {
return Held{}, false
}
return h, true
}
// List is every holder, by login.
func (l *Ledger) List() []Held {
entries, err := os.ReadDir(l.dir)
if err != nil {
return nil
}
var out []Held
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".json") {
if h, ok := l.Get(strings.TrimSuffix(e.Name(), ".json")); ok {
out = append(out, h)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].As < out[j].As })
return out
}
func (l *Ledger) write(path string, h Held) error {
raw, err := json.MarshalIndent(h, "", " ")
if err != nil {
return err
}
return writeWhole(path, append(raw, '\n'))
}
// Contribution is one entry of the contributions file the mesh delivers to the vault.
type Contribution struct {
From string `json:"from,omitempty"`
Node string `json:"node,omitempty"`
As string `json:"as"`
Secret string `json:"secret"`
}
// Contributions is what the mesh asks this vault to serve: the entries with a login and a secret file.
//
// **A file that does not say who is asked for is not "nobody".** One that is unreadable, not JSON, without a
// `given` list (missing or null), or written for another requirement than `secret` is refused, so a pass
// over it retires nobody. An empty `given` list is an answer: nobody is asked for.
func Contributions(receives string) ([]Contribution, error) {
raw, err := os.ReadFile(receives)
if err != nil {
return nil, err
}
var doc struct {
Requirement *string `json:"requirement"`
Given json.RawMessage `json:"given"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("the contributions file is not JSON: %w", err)
}
if doc.Requirement != nil && *doc.Requirement != "secret" {
return nil, fmt.Errorf("the contributions file is for %q, not for secret", *doc.Requirement)
}
if len(doc.Given) == 0 || string(doc.Given) == "null" {
return nil, errors.New("the contributions file has no `given` list, so it says nothing of who is asked for")
}
var given []Contribution
if err := json.Unmarshal(doc.Given, &given); err != nil {
return nil, fmt.Errorf("the contributions file's `given` is not a list of contributions: %w", err)
}
var out []Contribution
for _, g := range given {
if g.As != "" && g.Secret != "" {
out = append(out, g)
}
}
return out, nil
}
// delivered is the value the mesh delivered for c, without the newline a file may end with.
func delivered(c Contribution) (string, error) {
raw, err := os.ReadFile(c.Secret)
if err != nil {
return "", fmt.Errorf("the delivered secret is not readable: %v", err)
}
return strings.TrimSuffix(string(raw), "\n"), nil
}
// DeliveredFingerprint is the fingerprint and length of what the mesh currently delivers for c.
func DeliveredFingerprint(c Contribution) (string, int, error) {
v, err := delivered(c)
if err != nil {
return "", 0, err
}
return Fingerprint(v), len(v), nil
}
@@ -0,0 +1,129 @@
package main
// The custody ledger the TypeScript vault kept (its client.ts and test/ledger.test.ts), carried over whole:
// who holds a secret the controller delivered for this vault, since when, and its fingerprint — never the
// value. Its files are the same JSON, so the ledger on the vault's disk is read as it is.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestAFirstDeliveryIsAGrantTheSameAgainNothingANewValueARotation(t *testing.T) {
l, _ := NewLedger(t.TempDir())
t0 := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
t1 := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
held, outcome, err := l.Record("anchor-redis", "anchor", "first-value", t0)
if err != nil || outcome != Granted || held.Rotations != 0 || held.Since != t0.Format(isoMillis) ||
held.Fingerprint != Fingerprint("first-value") {
t.Fatalf("%+v %s %v", held, outcome, err)
}
if _, outcome, _ := l.Record("anchor-redis", "anchor", "first-value", t1); outcome != Unchanged {
t.Errorf("the same value again is %s", outcome)
}
held, outcome, _ = l.Record("anchor-redis", "anchor", "second-value", t1)
if outcome != Rotated || held.Rotations != 1 || held.Since != t0.Format(isoMillis) || held.Changed != t1.Format(isoMillis) {
t.Errorf("%+v %s", held, outcome)
}
if len(held.History) != 1 || held.History[0].Fingerprint != Fingerprint("first-value") || held.History[0].Until != t1.Format(isoMillis) {
t.Errorf("history %+v", held.History)
}
}
func TestTheLedgerHoldsFingerprintsNeverTheValue(t *testing.T) {
dir := t.TempDir()
l, _ := NewLedger(dir)
_, _, _ = l.Record("anchor-redis", "anchor", "the-actual-password", time.Now())
_, _, _ = l.Record("anchor-redis", "anchor", "the-rotated-password", time.Now())
entries, _ := os.ReadDir(dir)
for _, e := range entries {
raw, _ := os.ReadFile(filepath.Join(dir, e.Name()))
if strings.Contains(string(raw), "the-actual-password") || strings.Contains(string(raw), "the-rotated-password") {
t.Errorf("%s holds a value", e.Name())
}
info, _ := e.Info()
if info.Mode().Perm() != 0o600 {
t.Errorf("%s is %v", e.Name(), info.Mode())
}
}
}
func TestDeletingForgetsAHolderAndListingIsByLogin(t *testing.T) {
l, _ := NewLedger(t.TempDir())
_, _, _ = l.Record("b-app", "b", "x", time.Now())
_, _, _ = l.Record("a-app", "a", "y", time.Now())
if got := logins(l.List()); got != "a-app,b-app" {
t.Errorf("listed %s", got)
}
if !l.Delete("a-app") || l.Delete("a-app") {
t.Error("deleting found the wrong thing")
}
if got := logins(l.List()); got != "b-app" {
t.Errorf("listed %s", got)
}
}
func TestALoginIsANameNotAPath(t *testing.T) {
l, _ := NewLedger(t.TempDir())
if _, _, err := l.Record("../etc/passwd", "n", "v", time.Now()); err == nil {
t.Error("a path was taken for a login")
}
}
// A ledger the TypeScript vault wrote is read as it is.
func TestALedgerTheTypeScriptVaultWroteIsRead(t *testing.T) {
dir := t.TempDir()
written := `{
"as": "anchor-redis",
"consumer": "anchor",
"fingerprint": "sha256:abc",
"length": 40,
"since": "2026-09-20T10:00:00.000Z",
"changed": "2026-09-21T10:00:00.000Z",
"rotations": 1,
"history": [{"fingerprint": "sha256:old", "until": "2026-09-21T10:00:00.000Z"}]
}
`
_ = os.WriteFile(filepath.Join(dir, "anchor-redis.json"), []byte(written), 0o600)
l, _ := NewLedger(dir)
h, ok := l.Get("anchor-redis")
if !ok || h.Rotations != 1 || h.Length != 40 || h.History[0].Fingerprint != "sha256:old" {
t.Fatalf("%+v %v", h, ok)
}
}
func TestWhatTheMeshDeliversIsFingerprintedNeverReturned(t *testing.T) {
dir := t.TempDir()
secret := filepath.Join(dir, "anchor.redis.secret")
_ = os.WriteFile(secret, []byte("minted-value\n"), 0o600)
receives := filepath.Join(dir, "mesh.json")
doc, _ := json.Marshal(map[string]any{"requirement": "secret", "given": []map[string]string{
{"from": "redis", "node": "anchor", "as": "anchor-redis", "secret": secret},
{"from": "offer-only", "node": "anchor"},
}})
_ = os.WriteFile(receives, doc, 0o600)
asked, err := Contributions(receives)
if err != nil || len(asked) != 1 || asked[0].As != "anchor-redis" {
t.Fatalf("%+v %v", asked, err)
}
fp, length, err := DeliveredFingerprint(asked[0])
if err != nil || fp != Fingerprint("minted-value") || length != len("minted-value") {
t.Errorf("%s %d %v", fp, length, err)
}
if _, _, err := DeliveredFingerprint(Contribution{As: "x", Secret: filepath.Join(dir, "missing")}); err == nil || !strings.Contains(err.Error(), "not readable") {
t.Errorf("a missing file said %v", err)
}
}
func logins(hs []Held) string {
var out []string
for _, h := range hs {
out = append(out, h.As)
}
return strings.Join(out, ",")
}
+94
View File
@@ -0,0 +1,94 @@
// mesh-vault (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11): one binary, launched by its node's runtime
// and speaking MCP to it over stdio through the Go SDK. It holds the `mesh-vault` seat: it keeps every
// secret in its own store, sealed at rest and with a copy sealed to the operator key; it answers the seat's
// verb `get` with a secret sealed to the asker's key; it raises `secret-changed` without a value. Beside
// that it keeps the custody ledger of the pair credentials the controller still delivers to it.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
import (
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func say(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[mesh-vault] "+format+"\n", args...)
}
func emit(event string, body map[string]any) error { return stdio.Emit(event, body) }
// build makes the vault from the module's words: directories, never values.
func build() *Env {
env := &Env{Receives: os.Getenv("MESH_RECEIVES"), Root: os.Getenv("MESH_VAULT_ROOT")}
if l, err := NewLedger(os.Getenv("MESH_VAULT_LEDGER")); err == nil {
env.Ledger = l
} else {
say("%v", err)
}
if env.Ledger != nil && env.Receives != "" {
env.Custody = &Custody{Ledger: env.Ledger, Receives: env.Receives, Resource: "secret", Node: os.Getenv("MESH_NODE"),
Emit: emit, Now: time.Now, Log: say}
}
store, err := OpenStore(os.Getenv("MESH_VAULT_STORE"))
if err != nil {
env.Broken = fmt.Errorf("the vault could not open its store: %w", err)
say("%v", env.Broken)
return env
}
own, err := OwnKey(os.Getenv("MESH_VAULT_STATE"), store)
if err != nil {
env.Broken = fmt.Errorf("the vault could not open its key: %w", err)
say("%v", env.Broken)
return env
}
// Askers is left nil: the vault that runs answers nobody until who may ask arrives (askers.go).
env.Vault = &Vault{Store: store, Own: own, Emit: emit, Now: time.Now, Log: say}
return env
}
// adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what
// `operator key set` recorded; the controller keeps only a public half there), and seals again any
// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished.
func adoptOperatorKey(env *Env) {
if env.Vault == nil {
return
}
if raw := exportedOperatorKey(env.Root); env.Root != "" && raw != "" {
key, err := publicPEM(raw)
if err == nil {
_, err = env.Vault.SetOperatorKey(key, "the mesh's export")
}
env.refuseKey("")
if err != nil {
env.refuseKey(err.Error())
say("the operator key in the mesh's export is not taken, or not every copy is sealed to it yet: %v", err)
}
return
}
if _, err := env.Vault.Reseal(); err != nil {
say("%v", err)
}
}
func main() {
env := build()
go func() {
tick := time.NewTicker(time.Minute)
defer tick.Stop()
for {
adoptOperatorKey(env)
if env.Custody != nil {
env.Custody.Pass()
}
<-tick.C
}
}()
if err := stdio.Serve("", tools(env)); err != nil {
say("%v", err)
os.Exit(1)
}
}
@@ -0,0 +1,466 @@
package main
// Retirement of a holder the mesh stops asking for (novox/hq ADR 0230), in the words the controller reads:
// the `provisioner.retirement` event as mesh-controller's link.Retirement, the `provisioner_retirement`
// answer as link.RetirementState, and the acts as the SDK's harness takes them (mesh-sdk
// src/provisioner/index.ts) — the set repeated in `consumers`, a `why`, and the person's `by` and the
// `via` the controller's verbs say. testdata/retirement-*.json are those types marshalled by the controller
// at the commit its README names, and the tests hold this file's words to them.
//
// The vault is mark-only: it holds no access to switch off, so a retired holder keeps what it has until a
// person deletes it, and the mark — when and why — is in the holder's own ledger file, so a restart forgets
// nothing.
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
// EventRetirement is the one event a provider says about retirement (ADR 0230 §35).
const EventRetirement = "provisioner.retirement"
// The rules of ADR 0230 §15 and §18.
const (
StablePasses = 5
StableFor = 10 * time.Minute
RetireAtOnce = 3
RetireFraction = 0.5
SayAgainEvery = time.Hour
)
// RetiredConsumer is one holder in a retirement word or answer (link.RetiredConsumer).
type RetiredConsumer struct {
Consumer string `json:"consumer"`
Node string `json:"node,omitempty"`
RetiredAt string `json:"retired_at,omitempty"`
Why string `json:"why,omitempty"`
SizeBytes *int64 `json:"size_bytes,omitempty"`
Kind string `json:"kind,omitempty"`
Access string `json:"access,omitempty"`
}
// RetirementWord is the event's body (link.Retirement).
type RetirementWord struct {
Provider string `json:"provider"`
ProviderNode string `json:"provider-node"`
Change string `json:"change"`
Consumers []RetiredConsumer `json:"consumers"`
Held int `json:"held"`
Bound string `json:"bound,omitempty"`
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
Via string `json:"via,omitempty"`
At time.Time `json:"at"`
Since *time.Time `json:"since,omitempty"`
}
// RetirementWaiting, RetirementRejected and RetirementState are the answer to provisioner_retirement
// (link.RetirementWaiting, link.RetirementRejected, link.RetirementState).
type RetirementWaiting struct {
Consumers []RetiredConsumer `json:"consumers"`
Since string `json:"since"`
Held int `json:"held"`
}
type RetirementRejected struct {
Consumers []RetiredConsumer `json:"consumers"`
By string `json:"by"`
Why string `json:"why"`
At string `json:"at"`
}
type RetirementState struct {
Resource string `json:"resource"`
Node string `json:"node"`
Held []string `json:"held"`
StablePasses int `json:"stable_passes"`
StableForSeconds int `json:"stable_for_seconds,omitempty"`
RetiresBy string `json:"retires_by,omitempty"`
Bound string `json:"bound"`
Waiting *RetirementWaiting `json:"waiting"`
Rejected *RetirementRejected `json:"rejected"`
Retired []RetiredConsumer `json:"retired"`
}
// Act is a person's act on retirement: why, who (the person the controller proved) and through what.
type Act struct {
Why, By, Via string
}
// Custody is the pass over the contributions file that keeps the ledger, and what it remembers between
// passes about the holders the mesh no longer asks for.
type Custody struct {
Ledger *Ledger
Receives string
// Resource is the provision the vault provides; Node the machine it runs on.
Resource string
Node string
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
mu sync.Mutex
key string
count int
firstSeen time.Time
asked map[string]bool
read bool
waiting *heldSet
rejected *heldSet
}
type heldSet struct {
set []string
since time.Time
saidAt time.Time
held int
by, why string
}
func (c *Custody) resource() string {
if c.Resource == "" {
return "secret"
}
return c.Resource
}
// Pass records every delivered value, re-enables a retired holder asked for again, and counts the holders
// no longer asked for toward their retirement.
func (c *Custody) Pass() {
c.mu.Lock()
defer c.mu.Unlock()
asked, err := Contributions(c.Receives)
if err != nil {
c.Log("THE CONTRIBUTIONS FILE IS NOT USABLE (%s): %v — nothing is recorded or retired on this pass, and "+
"the count toward retiring anyone starts again", c.Receives, err)
c.key, c.count = "", 0
return
}
now := c.Now()
keep := map[string]bool{}
for _, a := range asked {
keep[a.As] = true
value, err := delivered(a)
if err != nil {
c.Log("%s: %v", a.As, err)
continue
}
held, outcome, err := c.Ledger.Record(a.As, a.Node, value, now)
if err != nil {
c.Log("%s: %v", a.As, err)
continue
}
if held.Retired != nil {
if err := c.Ledger.Mark(a.As, nil); err != nil {
c.Log("%s: asked for again, and its retirement mark could not be cleared: %v", a.As, err)
} else {
c.Log("%s: asked for again — re-enabled, its retirement mark cleared", a.As)
c.tell(RetirementWord{Change: "reenabled", Consumers: []RetiredConsumer{{Consumer: a.As, Node: a.Node}}}, now)
}
}
if outcome == Unchanged {
continue
}
event := "provisioned"
if outcome == Rotated {
event = "rotated"
}
c.Log("%s: %s (%s…, rotations %d)", held.As, outcome, held.Fingerprint[:19], held.Rotations)
c.say(event, map[string]any{"consumer": held.Consumer, "as": held.As, "fingerprint": held.Fingerprint, "rotations": held.Rotations})
}
c.asked, c.read = keep, true
c.countUnasked(now)
}
func (c *Custody) active() []Held {
var out []Held
for _, h := range c.Ledger.List() {
if h.Retired == nil {
out = append(out, h)
}
}
return out
}
func (c *Custody) countUnasked(now time.Time) {
active := c.active()
var unasked []string
for _, h := range active {
if !c.asked[h.As] {
unasked = append(unasked, h.As)
}
}
key := strings.Join(unasked, ",")
if c.waiting != nil && strings.Join(c.waiting.set, ",") != key {
c.settle(c.waiting, "the set the mesh no longer asks for changed", now)
c.waiting = nil
}
if c.rejected != nil && strings.Join(c.rejected.set, ",") != key {
c.settle(c.rejected, "the set the mesh no longer asks for changed", now)
c.rejected = nil
}
switch {
case len(unasked) == 0:
c.key, c.count = "", 0
return
case key != c.key:
c.key, c.count, c.firstSeen = key, 1, now
c.Log("no longer asked for: %s; retired once the same set holds for %d passes and %s (novox/hq ADR 0230)",
key, StablePasses, StableFor)
default:
c.count++
}
if c.rejected != nil || c.count < StablePasses || now.Sub(c.firstSeen) < StableFor {
return
}
if overTheBound(len(unasked), len(active)) {
c.wait(unasked, len(active), now)
return
}
c.retire(unasked, len(active), fmt.Sprintf("the mesh stopped asking for it: the same answer in %d passes over %s, from %s",
c.count, now.Sub(c.firstSeen).Round(time.Second), c.firstSeen.UTC().Format(time.RFC3339)), now)
}
func overTheBound(n, held int) bool {
return n > RetireAtOnce || (held > 1 && float64(n) > RetireFraction*float64(held))
}
func bound(held int) string {
return fmt.Sprintf("more than %d, or more than %d%% of the %d held", RetireAtOnce, int(RetireFraction*100), held)
}
func (c *Custody) wait(set []string, held int, now time.Time) {
if c.waiting == nil {
c.waiting = &heldSet{set: set, since: now, held: held, saidAt: now}
c.Log("RETIREMENT WAITS FOR A PERSON: the mesh no longer asks for %d of the %d holder(s) (%s), %s. "+
"Nothing is retired until `retire approve` or `retire reject` (novox/hq ADR 0230)",
len(set), held, strings.Join(set, ", "), bound(held))
} else if now.Sub(c.waiting.saidAt) < SayAgainEvery {
return
} else {
c.waiting.saidAt = now
}
since := c.waiting.since.UTC()
c.tell(RetirementWord{Change: "waiting", Consumers: c.named(set), Held: c.waiting.held, Since: &since}, now)
}
func (c *Custody) settle(s *heldSet, why string, now time.Time) {
c.Log("the set waiting on retirement (%s) is settled, nothing retired: %s", strings.Join(s.set, ", "), why)
c.tell(RetirementWord{Change: "settled", Consumers: c.named(s.set), Why: why}, now)
}
// retire marks each holder in set retired: the ledger keeps it, and its history, until a person deletes it.
func (c *Custody) retire(set []string, held int, why string, now time.Time) []string {
at := now.UTC().Format(isoMillis)
var done []string
for _, as := range set {
if err := c.Ledger.Mark(as, &RetiredMark{At: at, Why: why}); err != nil {
c.Log("%s: could not be marked retired, asked again once its set is stable again: %v", as, err)
continue
}
done = append(done, as)
}
c.key, c.count = "", 0
if len(done) == 0 {
return done
}
c.Log("%s: RETIRED, MARK ONLY — the vault holds no access to switch off, so each keeps what it holds until a "+
"person deletes it with `cleanup delete` (novox/hq ADR 0230): %s", strings.Join(done, ", "), why)
c.tell(RetirementWord{Change: "retired", Consumers: c.retiredOnes(done), Held: held, Why: why}, now)
return done
}
// named is each holder as the controller's words name it.
func (c *Custody) named(set []string) []RetiredConsumer {
out := make([]RetiredConsumer, 0, len(set))
for _, as := range set {
h, _ := c.Ledger.Get(as)
out = append(out, RetiredConsumer{Consumer: as, Node: h.Consumer})
}
return out
}
func (c *Custody) retiredOnes(set []string) []RetiredConsumer {
out := make([]RetiredConsumer, 0, len(set))
for _, as := range set {
h, _ := c.Ledger.Get(as)
out = append(out, retiredOne(h))
}
return out
}
func retiredOne(h Held) RetiredConsumer {
unknown := int64(-1)
r := RetiredConsumer{Consumer: h.As, Node: h.Consumer, SizeBytes: &unknown, Kind: "consumer", Access: "kept"}
if h.Retired != nil {
r.RetiredAt, r.Why = h.Retired.At, h.Retired.Why
}
return r
}
func sameSet(a, b []string) bool {
x, y := append([]string{}, a...), append([]string{}, b...)
sort.Strings(x)
sort.Strings(y)
return strings.Join(x, ",") == strings.Join(y, ",")
}
// Approve retires exactly the set waiting (or rejected) for a person, repeated in consumers; any other set
// is refused (ADR 0230 §20).
func (c *Custody) Approve(consumers []string, act Act) ([]string, error) {
if strings.TrimSpace(act.Why) == "" {
return nil, errors.New("approve: why is required")
}
c.mu.Lock()
defer c.mu.Unlock()
s := c.waiting
if s == nil {
s = c.rejected
}
if s == nil || !sameSet(s.set, consumers) {
return nil, fmt.Errorf("approve: nothing waits with exactly that set (%s); waiting: %s; rejected: %s. Look again",
strings.Join(consumers, ", "), setOf(c.waiting), setOf(c.rejected))
}
now := c.Now()
held := len(c.active())
c.Log("retirement of %s approved by %s: %s", strings.Join(s.set, ", "), someone(act.By), act.Why)
c.tell(RetirementWord{Change: "approved", Consumers: c.named(s.set), Held: held, Why: act.Why, By: act.By, Via: act.Via}, now)
set := s.set
c.waiting, c.rejected = nil, nil
return c.retire(set, held, "approved by "+someone(act.By)+": "+act.Why, now), nil
}
// Reject keeps the waiting set active, repeated in consumers, and asks no more about it while it stays the
// same set.
func (c *Custody) Reject(consumers []string, act Act) ([]string, error) {
if strings.TrimSpace(act.Why) == "" {
return nil, errors.New("reject: why is required")
}
c.mu.Lock()
defer c.mu.Unlock()
if c.waiting == nil || !sameSet(c.waiting.set, consumers) {
return nil, fmt.Errorf("reject: nothing waits with exactly that set (%s); waiting: %s. Look again",
strings.Join(consumers, ", "), setOf(c.waiting))
}
now := c.Now()
c.rejected = &heldSet{set: c.waiting.set, since: now, by: act.By, why: act.Why}
c.waiting = nil
c.Log("retirement of %s rejected by %s: %s; kept active while the mesh says the same",
strings.Join(c.rejected.set, ", "), someone(act.By), act.Why)
c.tell(RetirementWord{Change: "rejected", Consumers: c.named(c.rejected.set), Why: act.Why, By: act.By, Via: act.Via}, now)
return c.rejected.set, nil
}
// Delete forgets one retired holder on a person's word, its name repeated in confirm. Refused: before a
// pass has read the contributions file, a holder that is not retired, one the contributions file asks for
// now, and any act without a why or a matching confirm.
func (c *Custody) Delete(consumer, confirm string, act Act) error {
if strings.TrimSpace(act.Why) == "" {
return errors.New("delete: why is required")
}
if confirm != consumer {
return errors.New("delete: confirm must repeat the consumer's name")
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.read {
return errors.New("delete: no pass has read the contributions file yet, so whether the mesh asks for it is not known; try again in a minute")
}
h, ok := c.Ledger.Get(consumer)
if !ok {
return fmt.Errorf("delete: %s is not held here", consumer)
}
if h.Retired == nil {
return fmt.Errorf("delete: %s is active, not retired — only a retired holder is deleted", consumer)
}
asked, err := Contributions(c.Receives)
if err != nil {
return fmt.Errorf("delete: the contributions file cannot say whether the mesh asks for %s: %v", consumer, err)
}
for _, a := range asked {
if a.As == consumer {
return fmt.Errorf("delete: the mesh asks for %s again; the next pass re-enables it", consumer)
}
}
if !c.Ledger.Delete(consumer) {
return fmt.Errorf("delete: %s could not be deleted", consumer)
}
c.Log("%s: DELETED from the ledger, asked by %s: %s", consumer, someone(act.By), act.Why)
unknown := int64(-1)
c.tell(RetirementWord{Change: "deleted", Consumers: []RetiredConsumer{{Consumer: consumer, Node: h.Consumer, SizeBytes: &unknown}},
Why: act.Why, By: act.By, Via: act.Via}, c.Now())
return nil
}
func someone(by string) string {
if by == "" {
return "someone"
}
return by
}
func setOf(s *heldSet) string {
if s == nil || len(s.set) == 0 {
return "none"
}
return strings.Join(s.set, ", ")
}
// Retirement is the answer to provisioner_retirement.
func (c *Custody) Retirement() RetirementState {
c.mu.Lock()
defer c.mu.Unlock()
st := RetirementState{Resource: c.resource(), Node: c.Node, Held: []string{}, StablePasses: StablePasses,
StableForSeconds: int(StableFor.Seconds()), RetiresBy: "mark-only", Retired: []RetiredConsumer{}}
for _, h := range c.Ledger.List() {
if h.Retired == nil {
st.Held = append(st.Held, h.As)
} else {
st.Retired = append(st.Retired, retiredOne(h))
}
}
st.Bound = bound(len(st.Held))
if c.waiting != nil {
st.Waiting = &RetirementWaiting{Consumers: c.named(c.waiting.set),
Since: c.waiting.since.UTC().Format(time.RFC3339), Held: c.waiting.held}
}
if c.rejected != nil {
st.Rejected = &RetirementRejected{Consumers: c.named(c.rejected.set), By: c.rejected.by, Why: c.rejected.why,
At: c.rejected.since.UTC().Format(time.RFC3339)}
}
return st
}
// tell says one retirement word, with the provider, its machine, the bound and the time filled in.
func (c *Custody) tell(w RetirementWord, now time.Time) {
w.Provider, w.ProviderNode, w.At = c.resource(), c.Node, now.UTC()
if w.Bound == "" {
w.Bound = bound(len(c.active()))
}
if w.Consumers == nil {
w.Consumers = []RetiredConsumer{}
}
c.say(EventRetirement, wordBody(w))
}
// wordBody is a word as the map the SDK's Emit takes, through its JSON, so the bus carries exactly the tags.
func wordBody(w RetirementWord) map[string]any {
raw, _ := json.Marshal(w)
var m map[string]any
_ = json.Unmarshal(raw, &m)
return m
}
func (c *Custody) say(event string, body map[string]any) {
if c.Emit == nil {
return
}
if err := c.Emit(event, body); err != nil {
c.Log("%s could not be said: %v", event, err)
}
}
@@ -0,0 +1,537 @@
package main
// ADR 0230 in the vault's custody ledger, and its words held to the controller's: testdata/retirement-*.json
// are mesh-controller's link.Retirement and link.RetirementState marshalled at dc36536 (feat/57, with #317).
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
// custodyAt is a custody pass over a contributions file in dir, on a clock the test moves.
type custodyAt struct {
*Custody
bus *published
receives string
dir string
clock *time.Time
}
func newCustody(t *testing.T) custodyAt {
dir := t.TempDir()
l, _ := NewLedger(filepath.Join(dir, "ledger"))
bus := &published{}
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
c := custodyAt{bus: bus, receives: filepath.Join(dir, "mesh.json"), dir: dir, clock: &clock}
c.Custody = &Custody{Ledger: l, Receives: c.receives, Resource: "secret", Node: "novox", Emit: bus.emit,
Now: func() time.Time { return *c.clock }, Log: t.Logf}
return c
}
// ask writes a contributions file asking for these logins, each with its value.
func (c custodyAt) ask(values map[string]string) {
given := []map[string]string{}
for as, v := range values {
f := filepath.Join(c.dir, as+".secret")
_ = os.WriteFile(f, []byte(v), 0o600)
given = append(given, map[string]string{"node": "anchor", "as": as, "secret": f})
}
doc, _ := json.Marshal(map[string]any{"requirement": "secret", "given": given})
_ = os.WriteFile(c.receives, doc, 0o600)
}
// apps is n holders named a-app, b-app, …
func apps(n int) map[string]string {
out := map[string]string{}
for i := 0; i < n; i++ {
out[fmt.Sprintf("%c-app", 'a'+i)] = fmt.Sprint(i)
}
return out
}
func keepOnly(all map[string]string, n int) map[string]string {
names := make([]string, 0, len(all))
for k := range all {
names = append(names, k)
}
sort.Strings(names)
out := map[string]string{}
for _, k := range names[:n] {
out[k] = all[k]
}
return out
}
// passes runs n passes a minute apart.
func (c custodyAt) passes(n int) {
for i := 0; i < n; i++ {
c.Pass()
*c.clock = c.clock.Add(time.Minute)
}
}
// stableFor runs StablePasses passes over an hour: past both of ADR 0230's conditions.
func (c custodyAt) stable() {
for i := 0; i < StablePasses; i++ {
c.Pass()
*c.clock = c.clock.Add(15 * time.Minute)
}
}
func (c custodyAt) changes() []string {
var out []string
for _, e := range c.bus.events {
if e["event"] == EventRetirement {
out = append(out, e["body"].(map[string]any)["change"].(string))
} else {
out = append(out, e["event"].(string))
}
}
return out
}
func (c custodyAt) retired(as string) bool {
h, _ := c.Ledger.Get(as)
return h.Retired != nil
}
func waitingNames(st RetirementState) []string {
if st.Waiting == nil {
return nil
}
var out []string
for _, w := range st.Waiting.Consumers {
out = append(out, w.Consumer)
}
return out
}
func act(why string) Act { return Act{Why: why, By: "person.jochen", Via: "mesh-controller"} }
// ---- the controller's words ----------------------------------------------------------------------------
// paths is every key path in a JSON document, arrays read through their first element.
func paths(t *testing.T, raw []byte) map[string]bool {
t.Helper()
var v any
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatal(err)
}
out := map[string]bool{}
var walk func(prefix string, v any)
walk = func(prefix string, v any) {
switch x := v.(type) {
case map[string]any:
for k, e := range x {
out[prefix+k] = true
walk(prefix+k+".", e)
}
case []any:
if len(x) > 0 {
walk(prefix, x[0])
}
}
}
walk("", v)
return out
}
// sameWords says every key the vault says is a key the controller's type has, and every key the controller
// needs is said.
func sameWords(t *testing.T, what string, ours []byte, golden string, needed ...string) {
t.Helper()
theirs, err := os.ReadFile(filepath.Join("testdata", golden))
if err != nil {
t.Fatal(err)
}
want := paths(t, theirs)
got := paths(t, ours)
for p := range got {
if !want[p] {
t.Errorf("%s says %q, which the controller's type has no field for", what, p)
}
}
for _, p := range needed {
if !got[p] {
t.Errorf("%s does not say %q, which the controller reads", what, p)
}
}
// And decoding strictly into the shape of the controller's golden document's own Go type would succeed:
// the same kinds at the same keys.
var g, o any
_ = json.Unmarshal(theirs, &g)
_ = json.Unmarshal(ours, &o)
sameKinds(t, what, "", g, o)
}
func sameKinds(t *testing.T, what, at string, want, got any) {
switch w := want.(type) {
case map[string]any:
g, ok := got.(map[string]any)
if !ok {
if got != nil {
t.Errorf("%s: %q is %T, the controller's is an object", what, at, got)
}
return
}
for k, v := range g {
if wv, known := w[k]; known {
sameKinds(t, what, at+"."+k, wv, v)
}
}
case []any:
g, ok := got.([]any)
if !ok {
if got != nil {
t.Errorf("%s: %q is %T, the controller's is a list", what, at, got)
}
return
}
if len(w) > 0 {
for _, e := range g {
sameKinds(t, what, at+"[]", w[0], e)
}
}
default:
if got != nil && fmt.Sprintf("%T", want) != fmt.Sprintf("%T", got) {
t.Errorf("%s: %q is %T, the controller's is %T", what, at, got, want)
}
}
}
// Every retirement word the vault says is one the controller's link.ReadRetirement reads: its machine
// named, its change known, its consumers objects.
func TestTheRetirementWordsAreTheControllers(t *testing.T) {
c := newCustody(t)
all := apps(5)
c.ask(all)
c.passes(1)
c.ask(keepOnly(all, 1))
c.stable() // waiting: four of five
st := c.Retirement()
if _, err := c.Reject(waitingNames(st), act("kept for now")); err != nil {
t.Fatal(err)
}
if _, err := c.Approve(waitingNames(st), act("they are gone")); err != nil {
t.Fatal(err)
}
c.passes(1)
if err := c.Delete("b-app", "b-app", act("gone for good")); err != nil {
t.Fatal(err)
}
c.ask(all)
c.passes(1) // re-enabled
seen := map[string]bool{}
for i, e := range c.bus.events {
if e["event"] != EventRetirement {
continue
}
raw := []byte(c.bus.raw[i])
var outer struct {
Body json.RawMessage `json:"body"`
}
_ = json.Unmarshal(raw, &outer)
var w struct {
Change string `json:"change"`
ProviderNode string `json:"provider-node"`
Consumers []struct {
Consumer string `json:"consumer"`
} `json:"consumers"`
}
dec := json.NewDecoder(bytes.NewReader(outer.Body))
if err := dec.Decode(&w); err != nil {
t.Fatalf("a retirement word the controller cannot read: %v: %s", err, outer.Body)
}
if w.ProviderNode != "novox" || len(w.Consumers) == 0 || w.Consumers[0].Consumer == "" {
t.Errorf("a %s word names no machine or no consumer: %s", w.Change, outer.Body)
}
seen[w.Change] = true
sameWords(t, "the "+w.Change+" word", outer.Body, "retirement-word.json",
"provider", "provider-node", "change", "consumers", "consumers.consumer", "at")
}
for _, change := range []string{"waiting", "rejected", "approved", "retired", "deleted", "reenabled"} {
if !seen[change] {
t.Errorf("no %s word was said", change)
}
}
}
// The answer to provisioner_retirement is the controller's RetirementState, so `retire approve`, `retire
// reject` and `cleanup list` read the vault.
func TestTheRetirementAnswerIsTheControllers(t *testing.T) {
c := newCustody(t)
all := apps(5)
c.ask(all)
c.passes(1)
c.ask(keepOnly(all, 1))
c.stable()
raw, _ := json.Marshal(c.Retirement())
sameWords(t, "the waiting answer", raw, "retirement-state.json", "resource", "node", "held", "stable_passes",
"retires_by", "bound", "waiting", "waiting.consumers", "waiting.consumers.consumer", "waiting.since", "retired")
if _, err := c.Approve(waitingNames(c.Retirement()), act("gone")); err != nil {
t.Fatal(err)
}
raw, _ = json.Marshal(c.Retirement())
sameWords(t, "the retired answer", raw, "retirement-state.json", "retired.consumer", "retired.retired_at",
"retired.why", "retired.access")
st := c.Retirement()
if st.RetiresBy != "mark-only" || st.Resource != "secret" || st.Node != "novox" || len(st.Retired) != 4 ||
st.Retired[0].Access != "kept" {
t.Errorf("the answer is %+v", st)
}
}
// ---- the rules, one at a time ---------------------------------------------------------------------------
func TestTheCustodyPassRecordsAndRotates(t *testing.T) {
c := newCustody(t)
c.ask(map[string]string{"anchor-redis": "v1"})
c.passes(1)
c.ask(map[string]string{"anchor-redis": "v2"})
c.passes(1)
if got := strings.Join(c.changes(), ","); got != "provisioned,rotated" {
t.Errorf("announced %s", got)
}
for _, raw := range c.bus.raw {
if strings.Contains(raw, `"v1"`) || strings.Contains(raw, `"v2"`) {
t.Errorf("announced a value: %s", raw)
}
}
}
// A contributions file that does not say who is asked for is no answer: nobody is retired.
func TestAFileThatSaysNothingRetiresNobody(t *testing.T) {
for _, doc := range []string{"{not json", `{"requirement":"secret"}`, `{"requirement":"secret","given":null}`,
`{"requirement":"redis-cache","given":[]}`} {
c := newCustody(t)
c.ask(map[string]string{"anchor-redis": "v1"})
c.passes(1)
_ = os.WriteFile(c.receives, []byte(doc), 0o600)
c.passes(30)
if h, ok := c.Ledger.Get("anchor-redis"); !ok || h.Retired != nil {
t.Errorf("%s: the holder was retired or lost: %+v %v", doc, h, ok)
}
if _, err := Contributions(c.receives); err == nil {
t.Errorf("%s was read as an answer", doc)
}
}
if _, err := Contributions(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Error("a missing file was read as an answer")
}
}
// Five passes, not four, however much time has passed.
func TestRetiringTakesFivePasses(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
for i := 0; i < StablePasses-1; i++ {
c.Pass()
*c.clock = c.clock.Add(time.Hour)
}
if c.retired("c-app") {
t.Fatal("retired after four passes")
}
c.Pass()
if !c.retired("c-app") {
t.Fatal("not retired after five passes and hours")
}
}
// Ten minutes, not less, however many passes.
func TestRetiringTakesTenMinutes(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
for i := 0; i < 20; i++ {
c.Pass()
*c.clock = c.clock.Add(25 * time.Second)
}
if c.retired("c-app") {
t.Fatal("retired before ten minutes")
}
*c.clock = c.clock.Add(5 * time.Minute)
c.Pass()
if !c.retired("c-app") {
t.Fatal("not retired after twenty passes and ten minutes")
}
}
// More than three at once waits, though under half: 4 of 10.
func TestMoreThanThreeWaits(t *testing.T) {
c := newCustody(t)
c.ask(apps(10))
c.passes(1)
c.ask(keepOnly(apps(10), 6))
c.stable()
if got := waitingNames(c.Retirement()); len(got) != 4 {
t.Fatalf("four of ten did not wait: %v", got)
}
if c.retired("j-app") {
t.Fatal("retired while waiting")
}
}
// More than half waits, though three or fewer: 2 of 3.
func TestMoreThanHalfWaits(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 1))
c.stable()
if got := waitingNames(c.Retirement()); len(got) != 2 {
t.Fatalf("two of three did not wait: %v", got)
}
}
// Within both bounds it retires without a person: 3 of 7.
func TestWithinTheBoundRetires(t *testing.T) {
c := newCustody(t)
c.ask(apps(7))
c.passes(1)
c.ask(keepOnly(apps(7), 4))
c.stable()
if c.Retirement().Waiting != nil || !c.retired("g-app") {
t.Fatal("three of seven did not retire")
}
}
// A holder is retired, never deleted by a pass, keeps its history, and is re-enabled when asked again.
func TestAHolderNoLongerAskedForIsRetiredNotDeleted(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
c.stable()
h, ok := c.Ledger.Get("c-app")
if !ok || h.Retired == nil || h.Retired.Why == "" || h.Fingerprint != Fingerprint("2") {
t.Fatalf("%+v %v", h, ok)
}
c.ask(apps(3))
c.Pass()
if c.retired("c-app") {
t.Error("asked for again, the holder stayed retired")
}
if got := strings.Join(c.changes(), ","); got != "provisioned,provisioned,provisioned,retired,reenabled" {
t.Errorf("announced %s", got)
}
}
// A file that says nothing starts the count again.
func TestTheCountStartsAgain(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
c.passes(4)
_ = os.WriteFile(c.receives, []byte(`{"given":null}`), 0o600)
c.passes(1)
c.ask(keepOnly(apps(3), 2))
c.passes(4)
*c.clock = c.clock.Add(time.Hour)
if c.retired("c-app") {
t.Fatal("retired though the count was broken")
}
c.passes(1)
if !c.retired("c-app") {
t.Fatal("not retired after five unbroken passes and an hour")
}
}
// Approve and reject take exactly the set waiting, and refuse any other (ADR 0230 §20).
func TestApproveAndRejectTakeExactlyTheSetWaiting(t *testing.T) {
for _, decide := range []string{"approve", "reject"} {
c := newCustody(t)
c.ask(apps(5))
c.passes(1)
c.ask(keepOnly(apps(5), 1))
c.stable()
set := waitingNames(c.Retirement())
for _, wrong := range [][]string{set[:3], append(append([]string{}, set...), "a-app"), {}} {
if _, err := c.Approve(wrong, act("x")); err == nil {
t.Errorf("approve took the set %v", wrong)
}
if _, err := c.Reject(wrong, act("x")); err == nil {
t.Errorf("reject took the set %v", wrong)
}
}
if _, err := c.Approve(set, Act{}); err == nil {
t.Error("an approval without a why was taken")
}
reversed := append([]string{}, set...)
sort.Sort(sort.Reverse(sort.StringSlice(reversed)))
if decide == "approve" {
done, err := c.Approve(reversed, act("the four apps are gone"))
if err != nil || len(done) != 4 || !c.retired("b-app") || c.retired("a-app") {
t.Fatalf("%v %v", done, err)
}
} else {
if _, err := c.Reject(reversed, act("they come back")); err != nil {
t.Fatal(err)
}
c.passes(10)
if c.retired("b-app") || c.Retirement().Waiting != nil {
t.Error("a rejected set was retired or asked about again")
}
}
}
}
// retiredC is a custody with c-app retired, d-app active.
func retiredC(t *testing.T) custodyAt {
c := newCustody(t)
c.ask(apps(4))
c.passes(1)
c.ask(keepOnly(apps(4), 3))
c.stable()
if !c.retired("d-app") {
t.Fatal("d-app was not retired")
}
return c
}
// Each refusal of a delete, one at a time.
func TestEachDeleteRefusal(t *testing.T) {
fresh := newCustody(t)
fresh.ask(apps(1))
if err := fresh.Delete("a-app", "a-app", act("x")); err == nil || !strings.Contains(err.Error(), "no pass") {
t.Errorf("a delete before any pass read the file: %v", err)
}
c := retiredC(t)
if err := c.Delete("a-app", "a-app", act("x")); err == nil || !strings.Contains(err.Error(), "active") {
t.Errorf("an active holder: %v", err)
}
if err := c.Delete("d-app", "d-app", Act{By: "person.jochen"}); err == nil || !strings.Contains(err.Error(), "why") {
t.Errorf("no why: %v", err)
}
if err := c.Delete("d-app", "c-app", act("x")); err == nil || !strings.Contains(err.Error(), "confirm") {
t.Errorf("a confirm naming another: %v", err)
}
if err := c.Delete("nobody", "nobody", act("x")); err == nil {
t.Error("a holder not held")
}
c.ask(apps(4)) // asked for again, no pass yet
if err := c.Delete("d-app", "d-app", act("x")); err == nil || !strings.Contains(err.Error(), "asks for") {
t.Errorf("a holder the file asks for again: %v", err)
}
c.ask(keepOnly(apps(4), 3))
if err := c.Delete("d-app", "d-app", act("gone for good")); err != nil {
t.Fatal(err)
}
if _, ok := c.Ledger.Get("d-app"); ok {
t.Error("deleted, but still in the ledger")
}
last := c.bus.events[len(c.bus.events)-1]["body"].(map[string]any)
if last["change"] != "deleted" || last["by"] != "person.jochen" || last["via"] != "mesh-controller" {
t.Errorf("the deletion said %v", last)
}
}
+336
View File
@@ -0,0 +1,336 @@
package main
// The vault's own store (novox/hq ADR 0349 §7, ADR 0113 §22): one file per secret on the vault's own disk,
// every value in it sealed — once to the vault's own key, the copy the vault opens to answer `get`, and once
// to the operator key, the copy the operator opens when the vault is lost (ADR 0113 §21). No database and no
// bucket: the vault is installed before any other module (ADR 0113 §14), a database login would be a
// secret only the vault makes, and a bucket holds no secret, sealed or not (ADR 0201 §13).
//
// Each file is written whole and renamed into place, so a reader never sees half a record.
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// OperatorBox names the box an operator copy is sealed in: the NaCl anonymous box the operator key is made
// for today (`operator key make`) and `secret recover --from-export` opens.
const OperatorBox = "nacl-anonymous-box"
// Record is one secret as the store keeps it: never its value.
type Record struct {
Name string `json:"name"`
Generation int `json:"generation"`
// AtRest is the value sealed to the vault's own key (the ADR 0206 box).
AtRest seal.Box `json:"at-rest"`
// Operator is the value sealed to the operator key.
Operator OperatorCopy `json:"operator"`
MadeAt time.Time `json:"made-at"`
ChangedAt time.Time `json:"changed-at"`
ChangedBy string `json:"changed-by"`
}
// OperatorCopy is a value sealed to the operator key named in Key.
type OperatorCopy struct {
Box string `json:"box"`
Key string `json:"key"`
Sealed string `json:"sealed"`
}
// Store is the vault's directory of secrets.
type Store struct {
dir string
// failWrite, when a test sets it, fails the write of a record: how a failure partway is proven.
failWrite func(Record) error
// HandedMax is the size at which the record of hand-outs is rolled (0: HandedMaxDefault).
HandedMax int64
handedMu sync.Mutex
rolled int
}
// The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the
// oldest dropped, so a flood of asks costs at most (HandedKept+1) × HandedMaxDefault on the vault's disk.
const (
HandedMaxDefault = 1 << 20
HandedKept = 3
)
// OpenStore opens (and makes) the store at dir.
func OpenStore(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("the vault's store has no directory: MESH_VAULT_STORE is not set")
}
if err := os.MkdirAll(filepath.Join(dir, "secrets"), 0o700); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
var secretName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$`)
// validName says why name is not a secret's name, or nil. A name is words joined by `/`, never a path.
func validName(name string) error {
if len(name) == 0 || len(name) > 200 || !secretName.MatchString(name) {
return fmt.Errorf("%q is not a secret's name: lower-case words joined by /, at most 200 characters", name)
}
return nil
}
func (s *Store) pathOf(name string) string {
return filepath.Join(s.dir, "secrets", url.PathEscape(name)+".json")
}
// Read is the record of one secret, and whether there is one.
func (s *Store) Read(name string) (Record, bool, error) {
if err := validName(name); err != nil {
return Record{}, false, err
}
raw, err := os.ReadFile(s.pathOf(name))
if errors.Is(err, os.ErrNotExist) {
return Record{}, false, nil
}
if err != nil {
return Record{}, false, err
}
var r Record
if err := json.Unmarshal(raw, &r); err != nil {
return Record{}, false, fmt.Errorf("the record of %s is not readable: %w", name, err)
}
return r, true, nil
}
// Write keeps one record, replacing any earlier one.
func (s *Store) Write(r Record) error {
if err := validName(r.Name); err != nil {
return err
}
if s.failWrite != nil {
if err := s.failWrite(r); err != nil {
return err
}
}
raw, err := json.MarshalIndent(r, "", " ")
if err != nil {
return err
}
return writeWhole(s.pathOf(r.Name), append(raw, '\n'))
}
// Empty says whether the store holds no secret at all.
func (s *Store) Empty() (bool, error) {
all, err := s.List()
return len(all) == 0, err
}
// Handing is one answer to `get`, or one refusal, as the store records it (as ADR 0277 §25 records asks):
// who asked, for what, when, and what it got — never the value.
type Handing struct {
At time.Time `json:"at"`
To string `json:"to"`
Name string `json:"name"`
Generation int `json:"generation,omitempty"`
Outcome string `json:"outcome"`
Why string `json:"why,omitempty"`
}
// RecordHanding appends one handing to the store's record, synced before it returns. It says whether the
// record was rolled to make room.
func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
raw, err := json.Marshal(h)
if err != nil {
return false, err
}
s.handedMu.Lock()
defer s.handedMu.Unlock()
path := filepath.Join(s.dir, "handed.jsonl")
max := s.HandedBound()
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > max {
if err := s.roll(path); err != nil {
return false, err
}
rolled = true
}
return rolled, appendSynced(path, append(raw, '\n'))
}
// roll moves handed.jsonl to .1, .1 to .2 and so on, dropping what falls past HandedKept.
func (s *Store) roll(path string) error {
_ = os.Remove(fmt.Sprintf("%s.%d", path, HandedKept))
for i := HandedKept - 1; i >= 1; i-- {
if err := os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if err := os.Rename(path, path+".1"); err != nil {
return err
}
s.rolled++
return syncDir(s.dir)
}
// Rolled is how many times the record of hand-outs was rolled since the vault started.
func (s *Store) Rolled() int {
s.handedMu.Lock()
defer s.handedMu.Unlock()
return s.rolled
}
func appendSynced(path string, raw []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
// Handed is the latest handings, newest first, at most limit: the current record, then the rolled ones.
func (s *Store) Handed(limit int) ([]Handing, error) {
s.handedMu.Lock()
defer s.handedMu.Unlock()
path := filepath.Join(s.dir, "handed.jsonl")
out := []Handing{}
for i := 0; i <= HandedKept && len(out) < limit; i++ {
file := path
if i > 0 {
file = fmt.Sprintf("%s.%d", path, i)
}
raw, err := os.ReadFile(file)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
for j := len(lines) - 1; j >= 0 && len(out) < limit; j-- {
var h Handing
if json.Unmarshal([]byte(lines[j]), &h) == nil {
out = append(out, h)
}
}
}
return out, nil
}
// HandedBound is the size at which the record of hand-outs is rolled.
func (s *Store) HandedBound() int64 {
if s.HandedMax > 0 {
return s.HandedMax
}
return HandedMaxDefault
}
// List is every record, by name.
func (s *Store) List() ([]Record, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, "secrets"))
if err != nil {
return nil, err
}
var out []Record
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".json") {
continue
}
name, err := url.PathUnescape(strings.TrimSuffix(e.Name(), ".json"))
if err != nil {
continue
}
r, ok, err := s.Read(name)
if err != nil {
return nil, err
}
if ok {
out = append(out, r)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
type operatorFile struct {
Key string `json:"key"`
Since time.Time `json:"since"`
By string `json:"by"`
}
// OperatorKey is the operator's public key the store seals to, or "" when it has none.
func (s *Store) OperatorKey() (string, error) {
raw, err := os.ReadFile(filepath.Join(s.dir, "operator.json"))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
var f operatorFile
if err := json.Unmarshal(raw, &f); err != nil {
return "", err
}
return f.Key, nil
}
func (s *Store) setOperatorKey(key, by string, at time.Time) error {
raw, _ := json.MarshalIndent(operatorFile{Key: key, Since: at, By: by}, "", " ")
return writeWhole(filepath.Join(s.dir, "operator.json"), append(raw, '\n'))
}
// writeWhole writes a file readable by the vault alone, synced, then renames it into place and syncs the
// directory, so a crash leaves the old file or the new one, never neither.
func writeWhole(path string, raw []byte) error {
tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
if err := writeSynced(tmp, raw); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return syncDir(filepath.Dir(path))
}
// writeSynced writes a new file at 0600 and syncs it to disk.
func writeSynced(path string, raw []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
// syncDir syncs a directory, so a rename or a link in it is on disk.
func syncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
@@ -0,0 +1,56 @@
{
"resource": "secret",
"node": "novox",
"held": [
"a"
],
"held_sizes": {
"a": 1
},
"stable_passes": 5,
"stable_for_seconds": 600,
"retires_by": "mark-only",
"bound": "b",
"waiting": {
"consumers": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
],
"since": "t",
"held": 1
},
"rejected": {
"consumers": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
],
"by": "b",
"why": "w",
"at": "t"
},
"retired": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
]
}
@@ -0,0 +1,23 @@
{
"provider": "secret",
"provider-node": "novox",
"change": "waiting",
"consumers": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
],
"held": 1,
"bound": "b",
"why": "w",
"by": "person.x",
"via": "mesh-controller",
"at": "2026-10-14T09:00:00Z",
"since": "2026-10-14T09:00:00Z"
}
+399
View File
@@ -0,0 +1,399 @@
package main
// What the vault serves: the seat's verb `get`, and the three tools the TypeScript vault offered —
// secret_export, secret_holders and secret_verify — answering as they did; beside them secret_handed and
// secret_vault_export over the vault's own store, and ADR 0230's four retirement tools over the custody
// ledger, and vault_status. None returns a value: `get` returns one sealed to the asker's key, the others
// fingerprints and ciphertext only.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Env is what the tools work on. A part the module's words did not give is nil, and a tool that needs it
// says so.
type Env struct {
Vault *Vault
Ledger *Ledger
Receives string
Root string
Custody *Custody
// refused is why the operator key in the mesh's export was not taken, or empty.
mu sync.Mutex
refused string
// Broken is why the vault could not be built, said by `get`.
Broken error
}
func (e *Env) keyRefused() string {
e.mu.Lock()
defer e.mu.Unlock()
return e.refused
}
func (e *Env) refuseKey(why string) {
e.mu.Lock()
defer e.mu.Unlock()
e.refused = why
}
// toolDef is a tool with its caller given apart from its arguments, so a test can be the runtime.
type toolDef struct {
Name string
Description string
Input map[string]any
run func(caller string, a map[string]any) (any, error)
}
// Run answers a call the runtime made: the caller is the one it proved.
func (t toolDef) Run(a map[string]any) (any, error) { return t.run(stdio.CallerOf(a), a) }
func (t toolDef) runAs(caller string, a map[string]any) (any, error) { return t.run(caller, a) }
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func definitions(env *Env) []toolDef {
return []toolDef{
{Name: Seat + ".get",
Description: "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its " +
"generation; answered only to an asker the vault allows, as the runtime proved it (ADR 0349 §11). " +
"Never the value in the clear, and never to a private key.",
Input: map[string]any{"name": str("the secret's name"),
"public_key": str("the asker's X25519 public key as SPKI PEM — never a private key")},
run: func(caller string, a map[string]any) (any, error) {
if env.Vault == nil {
if env.Broken != nil {
return nil, env.Broken
}
return nil, errors.New("the vault is not ready")
}
name, _ := a["name"].(string)
key, _ := a["public_key"].(string)
return env.Vault.Get(caller, strings.TrimSpace(name), key)
}},
{Name: "secret_export",
Description: "The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " +
"of it; the operator, holding the private key off the mesh, recovers one with " +
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
"made before the mesh had an operator key.",
Input: map[string]any{"sealed": map[string]any{"type": "boolean",
"description": "include the sealed blobs (default true); false lists holders and the key only"}},
run: func(_ string, a map[string]any) (any, error) {
return exportAnswer(env.Root, a["sealed"] != false), nil
}},
{Name: "secret_holders",
Description: "Who holds a secret from this vault: each consumer's login, node and module, when it was " +
"granted, how many times it has been rotated and when, and the fingerprint of the current value. " +
"Fingerprints only — the value is never returned.",
run: func(string, map[string]any) (any, error) {
if env.Ledger == nil {
return map[string]any{"holders": []any{}, "count": 0, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}, nil
}
var asked []Contribution
if env.Receives != "" {
asked, _ = Contributions(env.Receives)
}
holders := []map[string]any{}
for _, h := range env.Ledger.List() {
row := map[string]any{}
raw, _ := json.Marshal(h)
_ = json.Unmarshal(raw, &row)
row["module"], row["asked"] = nil, false
for _, c := range asked {
if c.As == h.As {
row["module"], row["asked"] = c.From, true
}
}
holders = append(holders, row)
}
return map[string]any{"holders": holders, "count": len(holders)}, nil
}},
{Name: "secret_verify",
Description: "Check one holder's secret without seeing it: the fingerprint the vault recorded against the " +
"fingerprint of the value the mesh currently delivers here, and optionally against a fingerprint " +
"computed on the holder's own machine (sha256 of the file, as `sha256:<hex>`). Two ends agreeing " +
"proves they agree, not that either works — the login itself is the test.",
Input: map[string]any{"as": str("the holder's login, e.g. anchor-redis"),
"fingerprint": str("optional: sha256:<hex> of the value as the holder reads it, computed there — never the value")},
run: func(_ string, a map[string]any) (any, error) {
as, _ := a["as"].(string)
given, _ := a["fingerprint"].(string)
return verifyAnswer(env, as, given), nil
}},
{Name: "secret_handed",
Description: "Who was handed which secret by `get`, and when, and who was refused, newest first, as the " +
"vault's own store records it. Never a value.",
Input: map[string]any{"limit": map[string]any{"type": "number", "description": "how many (default 50)"}},
run: func(_ string, a map[string]any) (any, error) {
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
return env.Vault.Store.Handed(limitOf(a))
}},
{Name: "secret_vault_export",
Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " +
"`secret export`: saved to a file, `nox secret recover vault mesh-vault <name> --key <operator key file> " +
"--from-export <file> --out <file>` opens one with the operator's private key. Ciphertext only.",
run: func(string, map[string]any) (any, error) {
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
return vaultExport(env.Vault)
}},
{Name: "provisioner_retirement",
Description: "This provider's retirement (novox/hq ADR 0230): the holders it keeps active, a retirement waiting " +
"for a person, one a person rejected, and every holder retired with when and why. Mark-only: a retired " +
"holder keeps what it has until a person deletes it.",
run: func(string, map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
return env.Custody.Retirement(), nil
}},
{Name: "provisioner_retire_approve",
Description: "Retire exactly the set waiting for a person (or rejected earlier). Repeat the set in consumers; why is required.",
Input: actInput(true, false),
run: func(caller string, a map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
done, err := env.Custody.Approve(stringsOf(a["consumers"]), actOf(caller, a))
if err != nil {
return nil, err
}
return map[string]any{"retired": done}, nil
}},
{Name: "provisioner_retire_reject",
Description: "Keep the set waiting for a person active, though the mesh no longer asks for it. Repeat the set in consumers; why is required.",
Input: actInput(true, false),
run: func(caller string, a map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
kept, err := env.Custody.Reject(stringsOf(a["consumers"]), actOf(caller, a))
if err != nil {
return nil, err
}
return map[string]any{"kept": kept}, nil
}},
{Name: "provisioner_delete",
Description: "Delete one RETIRED holder from the custody ledger, its rotation history with it — never an active one. " +
"Repeat its name in confirm; why is required.",
Input: actInput(false, true),
run: func(caller string, a map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
consumer, _ := a["consumer"].(string)
confirm, _ := a["confirm"].(string)
if err := env.Custody.Delete(consumer, confirm, actOf(caller, a)); err != nil {
return nil, err
}
return map[string]any{"deleted": consumer, "freed_bytes": -1}, nil
}},
{Name: "vault_status",
Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " +
"key the mesh's export holds if the vault refused it, and how often the record of hand-outs was rolled.",
run: func(string, map[string]any) (any, error) {
if env.Vault == nil {
return map[string]any{"ready": false, "error": fmt.Sprint(env.Broken)}, nil
}
left, err := env.Vault.StillSealedToAnother()
if err != nil {
return nil, err
}
key, _ := env.Vault.Store.OperatorKey()
return map[string]any{"ready": true, "operator_key": key, "copies_on_an_earlier_key": left,
"operator_key_refused": env.keyRefused(), "handed_rolled": env.Vault.Store.Rolled(),
"ok": left == 0 && env.keyRefused() == "" && key != ""}, nil
}},
}
}
// actInput is the arguments of an act on retirement, as the SDK's harness takes them.
func actInput(set, one bool) map[string]any {
text := map[string]any{"type": "string"}
in := map[string]any{"why": text, "by": text, "via": text}
if set {
in["consumers"] = map[string]any{"type": "array", "items": text}
}
if one {
in["consumer"], in["confirm"] = text, text
}
return in
}
// actOf is an act's why, by and via. `by` is the person the controller names; with none, the caller the
// runtime proved.
func actOf(caller string, a map[string]any) Act {
act := Act{}
act.Why, _ = a["why"].(string)
act.By, _ = a["by"].(string)
act.Via, _ = a["via"].(string)
if act.By == "" {
act.By = caller
}
return act
}
func stringsOf(v any) []string {
list, _ := v.([]any)
out := []string{}
for _, x := range list {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
func limitOf(a map[string]any) int {
if v, ok := a["limit"].(float64); ok && v > 0 {
return int(v)
}
return 50
}
// keyFingerprint is a key's fingerprint as the controller's secrets.Fingerprint says it: sha256 of the key,
// its first eight bytes in hex.
func keyFingerprint(key string) string {
if key == "" {
return ""
}
sum := sha256.Sum256([]byte(strings.TrimSpace(key)))
return "sha256:" + hex.EncodeToString(sum[:8])
}
// vaultExport is the vault's operator copies in the shape `secret export` writes and `secret recover
// --from-export` reads: each as node "vault", module "mesh-vault" and the secret's name.
func vaultExport(v *Vault) (map[string]any, error) {
key, err := v.Store.OperatorKey()
if err != nil {
return nil, err
}
all, err := v.Store.List()
if err != nil {
return nil, err
}
kept, earlier := []map[string]any{}, []map[string]any{}
for _, rec := range all {
k := map[string]any{"node": "vault", "module": Seat, "name": rec.Name, "kind": "own", "origin": "made",
"sealed": rec.Operator.Sealed, "key": rec.Operator.Key, "made-at": rec.MadeAt}
if rec.Operator.Key == key {
kept = append(kept, k)
} else {
earlier = append(earlier, k)
}
}
return map[string]any{"export": 1, "operator-key": key, "fingerprint": keyFingerprint(key), "kept": kept,
"sealed-to-earlier-key": earlier}, nil
}
func exportAnswer(root string, withBlobs bool) map[string]any {
if root == "" {
return map[string]any{"available": false, "error": "this vault keeps no root secrets (MESH_VAULT_ROOT is not set)"}
}
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
if err != nil {
return map[string]any{"available": false, "error": "the mesh has not written an export here yet: " + err.Error()}
}
var doc struct {
Export int `json:"export"`
OperatorKey string `json:"operator-key"`
Fingerprint string `json:"fingerprint"`
Kept []map[string]any `json:"kept"`
Unrecoverable []map[string]any `json:"unrecoverable"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return map[string]any{"available": false, "error": "the export is not readable: " + err.Error()}
}
kept := []map[string]any{}
for _, k := range doc.Kept {
if !withBlobs {
k = map[string]any{"node": k["node"], "module": k["module"], "name": k["name"], "origin": k["origin"], "made-at": k["made-at"]}
}
kept = append(kept, k)
}
if doc.Unrecoverable == nil {
doc.Unrecoverable = []map[string]any{}
}
return map[string]any{"available": true, "export": doc.Export, "operator-key": doc.OperatorKey,
"fingerprint": doc.Fingerprint, "count": len(kept), "kept": kept, "unrecoverable": doc.Unrecoverable}
}
// exportedOperatorKey is the operator key in the export the mesh writes to the vault's root, or "".
func exportedOperatorKey(root string) string {
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
if err != nil {
return ""
}
var doc struct {
OperatorKey string `json:"operator-key"`
}
_ = json.Unmarshal(raw, &doc)
return doc.OperatorKey
}
func verifyAnswer(env *Env, as, given string) map[string]any {
if env.Ledger == nil {
return map[string]any{"as": as, "known": false, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}
}
recorded, ok := env.Ledger.Get(as)
if !ok {
return map[string]any{"as": as, "known": false, "error": "this vault holds nothing for " + as}
}
var delivered, deliveredErr any
var deliveredMatches any
var asked *Contribution
if env.Receives != "" {
all, _ := Contributions(env.Receives)
for i := range all {
if all[i].As == as {
asked = &all[i]
}
}
}
if asked == nil {
deliveredErr = "the mesh does not currently ask this vault to serve that login"
} else if fp, _, err := DeliveredFingerprint(*asked); err != nil {
deliveredErr = err.Error()
} else {
delivered, deliveredMatches = fp, fp == recorded.Fingerprint
}
var givenAny, givenMatches, earlier any
if given != "" {
givenAny, givenMatches = given, given == recorded.Fingerprint
was := false
for _, h := range recorded.History {
was = was || h.Fingerprint == given
}
earlier = was
}
return map[string]any{"as": recorded.As, "known": true, "recorded": recorded.Fingerprint,
"rotations": recorded.Rotations, "changed": recorded.Changed,
"delivered": delivered, "deliveredError": deliveredErr, "deliveredMatchesRecorded": deliveredMatches,
"given": givenAny, "givenMatchesRecorded": givenMatches, "givenIsAnEarlierValue": earlier,
"ok": deliveredMatches != false && givenMatches != false}
}
// tools are the definitions as the SDK serves them.
func tools(env *Env) []stdio.Tool {
var out []stdio.Tool
for _, d := range definitions(env) {
out = append(out, stdio.Tool{Name: d.Name, Description: d.Description, Input: d.Input, Run: d.Run})
}
return out
}
@@ -0,0 +1,152 @@
package main
// The tools the TypeScript vault offered, answering as they did: secret_export, secret_holders and
// secret_verify — none returns a value. And the seat's verb `get`, reached the way the runtime calls it.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
func toolNamed(t *testing.T, tools []toolDef, name string) toolDef {
t.Helper()
for _, tl := range tools {
if tl.Name == name {
return tl
}
}
t.Fatalf("no tool %s", name)
return toolDef{}
}
func TestTheManifestNamesWhatTheBundleServes(t *testing.T) {
raw, _ := os.ReadFile("../../module.json")
var m struct {
Tools []string `json:"tools"`
Claims []struct {
Name string `json:"name"`
Serves []string `json:"serves"`
} `json:"claims"`
Emits []string `json:"emits"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
served := map[string]bool{}
for _, tl := range definitions(&Env{}) {
served[tl.Name] = true
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("the manifest names the tool %s, which the bundle does not serve", name)
}
}
for _, c := range m.Claims {
for _, verb := range c.Serves {
if !served[c.Name+"."+verb] {
t.Errorf("the claim of %s serves %s, which the bundle does not", c.Name, verb)
}
}
}
for name := range served {
if !strings.HasPrefix(name, Seat+".") && !contains(m.Tools, name) {
t.Errorf("the bundle serves %s, which the manifest does not name", name)
}
}
if !contains(m.Emits, ChangedEvent) {
t.Errorf("the manifest does not say the vault emits %s", ChangedEvent)
}
}
func TestSecretExportAnswersTheMeshsExport(t *testing.T) {
root := t.TempDir()
env := &Env{Root: root}
export := toolNamed(t, definitions(env), "secret_export")
got, _ := export.Run(map[string]any{})
if got.(map[string]any)["available"] != false {
t.Errorf("no export said %v", got)
}
doc := `{"export":1,"operator-key":"b3A=","fingerprint":"sha256:f","kept":[{"node":"g14","module":"dunst","name":"token","kind":"own","origin":"made","sealed":"c2VhbGVk","key":"b3A=","made-at":"2026-10-01T00:00:00Z"}],"unrecoverable":[{"node":"ace","module":"x","name":"y"}]}`
_ = os.WriteFile(filepath.Join(root, "export.json"), []byte(doc), 0o600)
got, _ = export.Run(map[string]any{})
a := got.(map[string]any)
if a["available"] != true || a["count"] != 1 || a["operator-key"] != "b3A=" {
t.Errorf("the export said %v", a)
}
got, _ = export.Run(map[string]any{"sealed": false})
kept := got.(map[string]any)["kept"].([]map[string]any)
if _, ok := kept[0]["sealed"]; ok {
t.Error("sealed=false still returned the blobs")
}
}
func TestSecretHoldersAndVerifySayFingerprintsOnly(t *testing.T) {
dir := t.TempDir()
l, _ := NewLedger(filepath.Join(dir, "ledger"))
secret := filepath.Join(dir, "s")
_ = os.WriteFile(secret, []byte("the-value"), 0o600)
receives := filepath.Join(dir, "mesh.json")
doc, _ := json.Marshal(map[string]any{"given": []map[string]string{{"from": "redis", "node": "anchor", "as": "anchor-redis", "secret": secret}}})
_ = os.WriteFile(receives, doc, 0o600)
_, _, _ = l.Record("anchor-redis", "anchor", "the-value", time.Now())
env := &Env{Ledger: l, Receives: receives}
holders, _ := toolNamed(t, definitions(env), "secret_holders").Run(map[string]any{})
verify, _ := toolNamed(t, definitions(env), "secret_verify").Run(map[string]any{"as": "anchor-redis", "fingerprint": Fingerprint("the-value")})
unknown, _ := toolNamed(t, definitions(env), "secret_verify").Run(map[string]any{"as": "nobody"})
for _, answer := range []any{holders, verify, unknown} {
raw, _ := json.Marshal(answer)
if strings.Contains(string(raw), "the-value") {
t.Errorf("a tool returned the value: %s", raw)
}
}
v := verify.(map[string]any)
if v["ok"] != true || v["deliveredMatchesRecorded"] != true || v["givenMatchesRecorded"] != true {
t.Errorf("verify said %v", v)
}
h := holders.(map[string]any)
if h["count"] != 1 {
t.Errorf("holders said %v", h)
}
if unknown.(map[string]any)["known"] != false {
t.Errorf("an unknown login said %v", unknown)
}
}
// The seat's verb, called as the runtime calls it: the caller is the one the runtime proved, never an
// argument.
func TestTheGetVerbTakesItsCallerFromTheRuntime(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
get := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".get")
// No runtime proved a caller here, so the call is unproven whatever the arguments say.
if _, err := get.Run(map[string]any{"name": "pair/postgres/gitea", "public_key": asker.PublicKey, "caller": "g14.node-provisioner"}); err == nil {
t.Error("get answered a caller named in its arguments")
}
answer, err := get.runAs("g14.node-provisioner", map[string]any{"name": "pair/postgres/gitea", "public_key": asker.PublicKey})
if err != nil {
t.Fatal(err)
}
a := answer.(Answer)
if got, err := seal.Open(a.Sealed, asker.PrivateKey); err != nil || got != "x" {
t.Errorf("%q %v", got, err)
}
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
+301
View File
@@ -0,0 +1,301 @@
package main
// The vault's rules (novox/hq ADR 0349 §7 and §11; ADR 0113 §1, §2, §22): it holds every secret sealed at
// rest, with a copy sealed to the operator key; it hands one out only on request and reply, sealed to the
// asker's key, and only to an asker it allows; it says a secret changed — who, when, never a value.
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"regexp"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// Seat is the role this module holds.
const Seat = "mesh-vault"
// ChangedEvent is what the vault raises when a secret it holds changes: who, when, which, its generation.
const ChangedEvent = "secret-changed"
// Vault holds the secrets.
type Vault struct {
Store *Store
// Own is the vault's own key; the private half never leaves its state directory.
Own seal.KeyPair
// Askers decides who may ask `get` for what. Nil allows nobody.
Askers Askers
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
mu sync.Mutex
}
// Changed is what one change to a secret was. Never its value.
type Changed struct {
Name string `json:"name"`
Generation int `json:"generation"`
At time.Time `json:"at"`
By string `json:"by"`
Unchanged bool `json:"unchanged,omitempty"`
}
// Answer is `get`'s reply: the secret sealed to the asker's key, and which generation it is.
type Answer struct {
Name string `json:"name"`
Generation int `json:"generation"`
ChangedAt time.Time `json:"changed-at"`
Sealed seal.Box `json:"sealed"`
}
func (v *Vault) log(format string, args ...any) {
if v.Log != nil {
v.Log(format, args...)
}
}
// Put keeps value as the secret name: sealed to the vault's key and to the operator key, a new generation
// when it differs from the one held, and `secret-changed` said. The same value again changes nothing.
// Refused: a private key (it stays where it was made), and any value while the vault has no operator key —
// a secret only the vault could open is what ADR 0113 §22 rules out.
func (v *Vault) Put(name, value, by string) (Changed, error) {
if err := validName(name); err != nil {
return Changed{}, err
}
if value == "" {
return Changed{}, fmt.Errorf("%s: a secret is not empty", name)
}
if privateKeyIn(value) {
return Changed{}, fmt.Errorf("%s: that is a private key, and a private key stays where it was made and never reaches the vault (ADR 0113 §2)", name)
}
if by == "" {
by = "the vault"
}
v.mu.Lock()
defer v.mu.Unlock()
operator, err := v.Store.OperatorKey()
if err != nil {
return Changed{}, err
}
if operator == "" {
return Changed{}, fmt.Errorf("%s is not kept: the vault has no operator key to seal its copy to (ADR 0113 §22) — the mesh writes it with the export once `operator key set` has been run", name)
}
before, held, err := v.Store.Read(name)
if err != nil {
return Changed{}, err
}
if held {
if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value {
return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil
}
}
atRest, err := seal.Seal(value, v.Own.PublicKey)
if err != nil {
return Changed{}, err
}
forOperator, err := operatorSeal(operator, []byte(value))
if err != nil {
return Changed{}, err
}
now := v.Now().UTC()
rec := Record{Name: name, Generation: 1, AtRest: atRest,
Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator},
MadeAt: now, ChangedAt: now, ChangedBy: by}
if held {
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
}
if err := v.Store.Write(rec); err != nil {
return Changed{}, err
}
c := Changed{Name: name, Generation: rec.Generation, At: now, By: by}
v.announce(c)
return c, nil
}
// Mint makes a fresh value for name and keeps it: 30 random bytes, base64url without padding — 40
// characters, the controller's shape, which fits the tightest backend a minted password reaches.
func (v *Vault) Mint(name, by string) (Changed, error) {
raw := make([]byte, 30)
if _, err := rand.Read(raw); err != nil {
return Changed{}, err
}
return v.Put(name, base64.RawURLEncoding.EncodeToString(raw), by)
}
// announce raises `secret-changed`: who, when, which and its generation — never a value or a sealed copy.
func (v *Vault) announce(c Changed) {
if v.Emit == nil {
return
}
body := map[string]any{"name": c.Name, "by": c.By, "at": c.At.Format(time.RFC3339Nano), "generation": c.Generation}
if err := v.Emit(ChangedEvent, body); err != nil {
v.log("%s is kept at generation %d, but %s could not be said: %v", c.Name, c.Generation, ChangedEvent, err)
}
}
// recordHanding keeps who asked for what, and what they got, in the vault's own store, and says loudly
// when the record had to be rolled to make room.
func (v *Vault) recordHanding(h Handing) error {
h.At = v.Now().UTC()
rolled, err := v.Store.RecordHanding(h)
if err != nil {
v.log("the record of %s asking for %s could not be written: %v", h.To, h.Name, err)
return err
}
if rolled {
v.log("THE RECORD OF HAND-OUTS WAS ROLLED (%d times since the vault started): it reached its bound of %d bytes; "+
"the oldest of %d kept files is dropped. Many asks in little time is worth a look: secret_handed and vault_status",
v.Store.Rolled(), v.Store.HandedBound(), HandedKept)
}
return nil
}
// busUser is the shape of a caller the runtime proves (the SDK's): the one shape a refusal is recorded for.
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
// Get answers caller with the secret name sealed to publicKey, if caller may ask for it. The caller is the
// bus user the runtime proved, never an argument; an unproven call is refused.
func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
if caller == "" || caller == stdio.Unproven {
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
}
askers := v.Askers
if askers == nil {
askers = NoAsker{}
}
if !busUser.MatchString(caller) || len(caller) > 200 {
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
}
// A name is judged before anything is recorded, so an arbitrary argument fills no record.
if err := validName(name); err != nil {
return Answer{}, err
}
if ok, why := askers.May(caller, name); !ok {
v.log("refused %s to %s: %s", name, caller, why)
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
}
key, err := askerKey(publicKey)
if err != nil {
return Answer{}, err
}
rec, held, err := v.Store.Read(name)
if err != nil {
return Answer{}, err
}
if !held {
return Answer{}, fmt.Errorf("the vault holds no secret named %s", name)
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
return Answer{}, fmt.Errorf("%s does not open with the vault's key: %w", name, err)
}
sealed, err := seal.Seal(value, key)
if err != nil {
return Answer{}, err
}
if err := v.recordHanding(Handing{To: caller, Name: name, Generation: rec.Generation, Outcome: "handed"}); err != nil {
return Answer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
}
v.log("handed %s generation %d to %s, sealed to its key", name, rec.Generation, caller)
return Answer{Name: name, Generation: rec.Generation, ChangedAt: rec.ChangedAt, Sealed: sealed}, nil
}
// SetOperatorKey makes key (SPKI PEM) the operator key, then seals again to it every operator copy that is
// not yet — see Reseal. It returns how many copies it sealed again.
//
// The key is recorded first and each record says which key its copy is sealed to, so that record is the
// progress: a failure partway leaves the rest for the next call, and the same key given again (every minute
// from the mesh's export) finishes them. Nothing returns early while a copy is sealed to another key.
func (v *Vault) SetOperatorKey(key, by string) (int, error) {
raw, err := checkOperatorKey(key)
if err != nil {
return 0, err
}
v.mu.Lock()
defer v.mu.Unlock()
current, err := v.Store.OperatorKey()
if err != nil {
return 0, err
}
if current != raw {
if err := v.Store.setOperatorKey(raw, by, v.Now().UTC()); err != nil {
return 0, err
}
v.log("the operator key is now %s (by %s); every operator copy is sealed again to it", raw, by)
}
return v.reseal(raw)
}
// Reseal seals again to the current operator key every copy sealed to another one, and says how many it
// did. Called every minute, so a copy a failure left behind is not left for ever.
func (v *Vault) Reseal() (int, error) {
v.mu.Lock()
defer v.mu.Unlock()
key, err := v.Store.OperatorKey()
if err != nil || key == "" {
return 0, err
}
return v.reseal(key)
}
func (v *Vault) reseal(key string) (int, error) {
all, err := v.Store.List()
if err != nil {
return 0, err
}
n := 0
var failed []error
for _, rec := range all {
if rec.Operator.Key == key && rec.Operator.Box == OperatorBox {
continue
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
failed = append(failed, fmt.Errorf("%s does not open with the vault's key: %w", rec.Name, err))
continue
}
sealed, err := operatorSeal(key, []byte(value))
if err == nil {
rec.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: sealed}
err = v.Store.Write(rec)
}
if err != nil {
failed = append(failed, fmt.Errorf("%s: %w", rec.Name, err))
continue
}
n++
}
if len(failed) > 0 {
err := fmt.Errorf("%d operator copies are still sealed to an earlier key, tried again on the next pass: %w",
len(failed), errors.Join(failed...))
v.log("%v", err)
return n, err
}
return n, nil
}
// StillSealedToAnother is how many operator copies are not yet sealed to the current operator key.
func (v *Vault) StillSealedToAnother() (int, error) {
key, err := v.Store.OperatorKey()
if err != nil {
return 0, err
}
all, err := v.Store.List()
if err != nil {
return 0, err
}
n := 0
for _, rec := range all {
if rec.Operator.Key != key {
n++
}
}
return n, nil
}
@@ -0,0 +1,730 @@
package main
// ADR 0349's vault test list, the part this package (V1, issue 674) answers: no bucket holds a secret; no
// private key is ever sent to the vault; every secret has a copy sealed to the operator key; `get` answers
// only an allowed asker, sealed to its key; `secret-changed` carries no value. The rest of that list (a
// consumer binding remakes the provider's credential, an accepted value is not remade, issued and typed
// values) is V2's and V5's.
import (
"crypto/ecdh"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/nacl/box"
)
// published is everything the vault said on the bus during a test, as it said it.
type published struct {
mu sync.Mutex
events []map[string]any
raw []string
}
func (p *published) emit(event string, body map[string]any) error {
p.mu.Lock()
defer p.mu.Unlock()
b, _ := json.Marshal(map[string]any{"event": event, "body": body})
p.raw = append(p.raw, string(b))
p.events = append(p.events, map[string]any{"event": event, "body": body})
return nil
}
// operatorKey is an operator key the way the controller's `operator key make` makes one: NaCl, raw X25519,
// base64 — the private half kept by the test, as the operator keeps it off the mesh.
func operatorKey(t *testing.T) (public, private string) {
t.Helper()
pub, priv, err := box.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(pub[:]), base64.StdEncoding.EncodeToString(priv[:])
}
// asPEM is a raw operator key as the SPKI PEM the vault takes.
func asPEM(t *testing.T, raw string) string {
t.Helper()
p, err := publicPEM(raw)
if err != nil {
t.Fatal(err)
}
return p
}
// openOperator opens an operator copy the way `secret recover --from-export` does.
func openOperator(t *testing.T, public, private, sealed string) string {
t.Helper()
var pub, priv [32]byte
p, _ := base64.StdEncoding.DecodeString(public)
q, _ := base64.StdEncoding.DecodeString(private)
copy(pub[:], p)
copy(priv[:], q)
blob, err := base64.StdEncoding.DecodeString(sealed)
if err != nil {
t.Fatal(err)
}
out, ok := box.OpenAnonymous(nil, blob, &pub, &priv)
if !ok {
t.Fatal("the operator copy does not open with the operator's key")
}
return string(out)
}
type testVault struct {
*Vault
dir string
bus *published
operatorPublic string
operatorPrivate string
}
func newVault(t *testing.T, askers Askers) testVault {
t.Helper()
dir := t.TempDir()
store, err := OpenStore(filepath.Join(dir, "store"))
if err != nil {
t.Fatal(err)
}
own, err := OwnKey(filepath.Join(dir, "state"), store)
if err != nil {
t.Fatal(err)
}
bus := &published{}
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
v := &Vault{Store: store, Own: own, Askers: askers, Emit: bus.emit,
Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Log: t.Logf}
pub, priv := operatorKey(t)
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil {
t.Fatal(err)
}
return testVault{Vault: v, dir: dir, bus: bus, operatorPublic: pub, operatorPrivate: priv}
}
// filesUnder is every file's content under dir.
func filesUnder(t *testing.T, dir string) map[string]string {
t.Helper()
out := map[string]string{}
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
raw, _ := os.ReadFile(path)
out[path] = string(raw)
}
return nil
})
return out
}
// ---- no bucket holds a secret ---------------------------------------------------------------------------
// The manifest declares no bucket, and the code opens none: the vault's only place for a secret is its own
// store on its own disk (ADR 0349 §7, ADR 0201 §13).
func TestNoBucketHoldsASecret(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var manifest map[string]any
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
if s, ok := manifest["state"]; ok {
t.Errorf("the manifest declares buckets %v: a secret is in no bucket, sealed or not (ADR 0201 §13)", s)
}
sources, _ := filepath.Glob("*.go")
bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`)
for _, f := range sources {
if strings.HasSuffix(f, "_test.go") {
continue
}
code, _ := os.ReadFile(f)
if m := bucket.Find(code); m != nil {
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
}
}
// And what the vault publishes while it holds, changes and hands out secrets carries none of them, in
// the clear or sealed.
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
value := "the-password-nobody-may-see"
if _, err := v.Put("pair/postgres/gitea", value, "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
if err != nil {
t.Fatal(err)
}
rec, _, _ := v.Store.Read("pair/postgres/gitea")
for _, said := range v.bus.raw {
for _, secret := range []string{value, answer.Sealed.Ct, rec.AtRest.Ct, rec.Operator.Sealed} {
if strings.Contains(said, secret) {
t.Errorf("the vault published %s, which carries a secret or a sealed copy of one", said)
}
}
}
}
// Every secret is encrypted at rest: no file the vault writes holds a value in the clear, and each opens
// only with the vault's own key.
func TestEverySecretIsEncryptedAtRest(t *testing.T) {
v := newVault(t, nil)
values := map[string]string{"own/g14/dunst/token": "tok-1-plain", "pair/postgres/gitea": "pw-2-plain"}
for name, value := range values {
if _, err := v.Put(name, value, "the test"); err != nil {
t.Fatal(err)
}
}
for path, content := range filesUnder(t, filepath.Join(v.dir, "store")) {
for _, value := range values {
if strings.Contains(content, value) {
t.Errorf("%s holds a value in the clear", path)
}
}
if info, _ := os.Stat(path); info.Mode().Perm()&0o077 != 0 {
t.Errorf("%s is readable by others: %v", path, info.Mode())
}
}
for name, value := range values {
rec, ok, err := v.Store.Read(name)
if err != nil || !ok {
t.Fatalf("%s: %v %v", name, ok, err)
}
got, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil || got != value {
t.Errorf("%s at rest opens to %q (%v), not its value", name, got, err)
}
}
}
// ---- no private key is accepted -------------------------------------------------------------------------
func TestNoPrivateKeyIsAccepted(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
keys, _ := seal.GenerateKeyPair()
ssh := "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----\n"
rsa := "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\n-----END RSA PRIVATE KEY-----\n"
// Not as a secret to hold: a private key stays where it was made (ADR 0113 §2).
for _, value := range []string{keys.PrivateKey, ssh, rsa, "prefix " + keys.PrivateKey} {
if _, err := v.Put("own/g14/sshd/host", value, "the test"); err == nil {
t.Errorf("the vault took a private key as a secret: %.40q", value)
}
}
if _, ok, _ := v.Store.Read("own/g14/sshd/host"); ok {
t.Error("a refused private key was kept all the same")
}
// Not as the key to seal an answer to.
if _, err := v.Put("pair/postgres/gitea", "pw", "the test"); err != nil {
t.Fatal(err)
}
raw32 := base64.StdEncoding.EncodeToString(make([]byte, 32))
for _, key := range []string{keys.PrivateKey, ssh, raw32, "", "not a key"} {
if _, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", key); err == nil {
t.Errorf("get sealed an answer to %.40q, which is not a PEM public key", key)
}
}
// Not as the operator key: neither a PEM private key nor a raw key, which looks the same as a raw
// private one.
rawPub, rawPriv := operatorKey(t)
for _, key := range []string{keys.PrivateKey, rawPriv, rawPub} {
if _, err := v.SetOperatorKey(key, "the test"); err == nil {
t.Errorf("the vault took %.20q as the operator key", key)
}
}
if k, _ := v.Store.OperatorKey(); k != v.operatorPublic {
t.Error("a refused operator key replaced the operator key")
}
// And nothing it publishes or writes names one that it refused.
for _, said := range v.bus.raw {
if strings.Contains(said, "PRIVATE KEY") {
t.Errorf("the vault published a private key: %s", said)
}
}
for path, content := range filesUnder(t, filepath.Join(v.dir, "store")) {
if strings.Contains(content, "PRIVATE KEY") {
t.Errorf("%s holds a private key", path)
}
}
}
// ---- every secret has an operator-sealed copy -----------------------------------------------------------
func TestEverySecretHasAnOperatorSealedCopy(t *testing.T) {
v := newVault(t, nil)
values := map[string]string{"own/g14/dunst/token": "a", "pair/postgres/gitea": "b"}
for name, value := range values {
if _, err := v.Put(name, value, "the test"); err != nil {
t.Fatal(err)
}
}
if _, err := v.Mint("pair/postgres/keycloak", "the test"); err != nil {
t.Fatal(err)
}
all, err := v.Store.List()
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("the store lists %d secrets, not 3", len(all))
}
for _, rec := range all {
if rec.Operator.Key != v.operatorPublic || rec.Operator.Box != OperatorBox {
t.Errorf("%s is sealed to %q in %q, not to the operator key", rec.Name, rec.Operator.Key, rec.Operator.Box)
}
got := openOperator(t, v.operatorPublic, v.operatorPrivate, rec.Operator.Sealed)
want, _ := seal.Open(rec.AtRest, v.Own.PrivateKey)
if got != want || got == "" {
t.Errorf("%s: the operator copy opens to %q, the vault's to %q", rec.Name, got, want)
}
}
// A new operator key: every copy is sealed again to it, and none stays sealed to the old one.
pub, priv := operatorKey(t)
n, err := v.SetOperatorKey(asPEM(t, pub), "the test")
if err != nil || n != 3 {
t.Fatalf("a new operator key sealed %d copies again (%v), not 3", n, err)
}
all, _ = v.Store.List()
for _, rec := range all {
if rec.Operator.Key != pub {
t.Errorf("%s is still sealed to the earlier operator key", rec.Name)
}
openOperator(t, pub, priv, rec.Operator.Sealed)
}
}
// Without an operator key the vault keeps nothing: a secret only it could open is what ADR 0113 §22 rules
// out, so the refusal is loud rather than a copy quietly missing.
func TestNoOperatorKeyKeepsNothing(t *testing.T) {
dir := t.TempDir()
store, _ := OpenStore(filepath.Join(dir, "store"))
own, _ := OwnKey(filepath.Join(dir, "state"), store)
v := &Vault{Store: store, Own: own, Emit: (&published{}).emit, Now: time.Now, Log: t.Logf}
if _, err := v.Put("own/g14/dunst/token", "x", "the test"); err == nil || !strings.Contains(err.Error(), "operator key") {
t.Fatalf("a secret was kept with no operator key to seal its copy to: %v", err)
}
if all, _ := store.List(); len(all) != 0 {
t.Fatalf("the store holds %d secrets", len(all))
}
}
// ---- get answers only an allowed asker, sealed to its key -----------------------------------------------
func TestGetAnswersOnlyAnAllowedAskerSealedToItsKey(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "the-value", "the test"); err != nil {
t.Fatal(err)
}
if _, err := v.Put("own/ace/dunst/token", "another", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
if err != nil {
t.Fatal(err)
}
if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "the-value" {
t.Fatalf("the answer opens to %q (%v)", got, err)
}
other, _ := seal.GenerateKeyPair()
if _, err := seal.Open(answer.Sealed, other.PrivateKey); err == nil {
t.Error("the answer opens with a key it was not sealed to")
}
if _, err := seal.Open(answer.Sealed, v.Own.PrivateKey); err == nil {
t.Error("the answer opens with the vault's own key")
}
if answer.Generation != 1 || answer.Name != "pair/postgres/gitea" {
t.Errorf("the answer says %+v", answer)
}
refusals := []struct{ caller, name, why string }{
{"ace.node-provisioner", "pair/postgres/gitea", "an asker not on the list"},
{"g14.node-provisioner", "own/ace/dunst/token", "a secret the asker is not allowed"},
{stdio.Unproven, "pair/postgres/gitea", "a call whose caller the runtime did not prove"},
{"", "pair/postgres/gitea", "no caller at all"},
{"g14.node-provisioner", "pair/postgres/nothing", "a secret the vault does not hold"},
}
for _, r := range refusals {
if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil {
t.Errorf("get answered %s: %+v", r.why, a)
}
}
}
// Until who may ask is decided by a node's declaration and a module's own account (V4, P3), the vault
// that runs answers nobody.
func TestTheRunningVaultAnswersNobodyYet(t *testing.T) {
v := newVault(t, nil) // what main builds: no list
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
for _, caller := range []string{"g14.node-provisioner", "novox.mesh-provisioner", "controller", "person.jochen", "novox.node-tools"} {
if _, err := v.Get(caller, "pair/postgres/gitea", asker.PublicKey); err == nil {
t.Errorf("the running vault answered %s", caller)
}
}
}
// The node-engine's sealing key (mesh-host internal/identity/sealing.go: raw X25519, base64, the NaCl
// anonymous box) is the same curve as the ADR 0206 box. A node's client asks with that key's public half as
// SPKI PEM, and opens the answer with its private half as PKCS #8 — no second key on the node, and no NaCl
// in the vault's answers. This is the path V4 builds on.
func TestANodesSealingKeyOpensTheAnswer(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "for-the-node", "the test"); err != nil {
t.Fatal(err)
}
// As mesh-host's GenerateSealingKey makes it.
priv, _ := ecdh.X25519().GenerateKey(rand.Reader)
nodePublic := base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes())
nodePrivate := base64.StdEncoding.EncodeToString(priv.Bytes())
rawPub, _ := base64.StdEncoding.DecodeString(nodePublic)
pubKey, _ := ecdh.X25519().NewPublicKey(rawPub)
spki, _ := x509.MarshalPKIXPublicKey(pubKey)
askWith := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}))
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", askWith)
if err != nil {
t.Fatal(err)
}
rawPriv, _ := base64.StdEncoding.DecodeString(nodePrivate)
privKey, _ := ecdh.X25519().NewPrivateKey(rawPriv)
pkcs8, _ := x509.MarshalPKCS8PrivateKey(privKey)
openWith := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}))
if got, err := seal.Open(answer.Sealed, openWith); err != nil || got != "for-the-node" {
t.Fatalf("the node's own sealing key does not open the answer: %q %v", got, err)
}
}
// ---- secret-changed carries no value --------------------------------------------------------------------
func TestSecretChangedCarriesNoValue(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "first-value", "g14.mesh-vault-test"); err != nil {
t.Fatal(err)
}
if _, err := v.Put("pair/postgres/gitea", "second-value", "person.jochen"); err != nil {
t.Fatal(err)
}
if _, err := v.Mint("own/g14/dunst/token", "the vault"); err != nil {
t.Fatal(err)
}
var changed []map[string]any
for _, e := range v.bus.events {
if e["event"] == ChangedEvent {
changed = append(changed, e["body"].(map[string]any))
}
}
if len(changed) != 3 {
t.Fatalf("%d secret-changed events, not 3: %v", len(changed), v.bus.events)
}
for i, body := range changed {
keys := make([]string, 0, len(body))
for k := range body {
keys = append(keys, k)
}
for _, k := range keys {
switch k {
case "name", "by", "at", "generation":
default:
t.Errorf("secret-changed carries %q, beyond who, when, which and its generation", k)
}
}
if body["by"] == "" || body["at"] == "" {
t.Errorf("secret-changed %d says not who or when: %v", i, body)
}
}
if changed[1]["by"] != "person.jochen" || changed[1]["generation"] != 2 {
t.Errorf("the second change says %v", changed[1])
}
minted, _, _ := v.Store.Read("own/g14/dunst/token")
mintedValue, _ := seal.Open(minted.AtRest, v.Own.PrivateKey)
for _, said := range v.bus.raw {
for _, value := range []string{"first-value", "second-value", mintedValue} {
if strings.Contains(said, value) {
t.Errorf("an event carries a value: %s", said)
}
}
}
}
// The same value again is no change: no new generation, no event.
func TestTheSameValueAgainIsNoChange(t *testing.T) {
v := newVault(t, nil)
for i := 0; i < 2; i++ {
if _, err := v.Put("pair/postgres/gitea", "same", "the test"); err != nil {
t.Fatal(err)
}
}
rec, _, _ := v.Store.Read("pair/postgres/gitea")
if rec.Generation != 1 {
t.Errorf("the same value twice is generation %d", rec.Generation)
}
n := 0
for _, e := range v.bus.events {
if e["event"] == ChangedEvent {
n++
}
}
if n != 1 {
t.Errorf("%d secret-changed events for one value", n)
}
}
// A secret's name is a name, not a path.
func TestASecretsNameIsNotAPath(t *testing.T) {
v := newVault(t, nil)
for _, name := range []string{"../etc/passwd", "/abs", "a//b", "A/b", "a/../b", "", strings.Repeat("a", 300)} {
if _, err := v.Put(name, "x", "the test"); err == nil {
t.Errorf("the vault kept a secret named %q", name)
}
}
}
// The vault's own key is made once, kept beside it, and the same on the next start.
func TestTheVaultsOwnKeyIsMadeOnce(t *testing.T) {
dir := t.TempDir()
a, err := OwnKey(dir, nil)
if err != nil {
t.Fatal(err)
}
b, err := OwnKey(dir, nil)
if err != nil {
t.Fatal(err)
}
if a != b {
t.Error("the vault's key changed between two starts")
}
info, _ := os.Stat(filepath.Join(dir, OwnKeyFile))
if info.Mode().Perm() != 0o600 {
t.Errorf("the vault's key is %v", info.Mode())
}
}
// The vault's key gone while its store holds secrets: no new key is made, and the vault does not start.
func TestAMissingKeyBesideSecretsIsRefused(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(v.dir, "state", OwnKeyFile)); err != nil {
t.Fatal(err)
}
if _, err := OwnKey(filepath.Join(v.dir, "state"), v.Store); err == nil || !strings.Contains(err.Error(), "MISSING") {
t.Fatalf("a new key was made beside secrets sealed to the lost one: %v", err)
}
if _, err := os.Stat(filepath.Join(v.dir, "state", OwnKeyFile)); err == nil {
t.Error("a key file was written all the same")
}
}
// A new operator key that fails partway leaves no copy sealed to the earlier key for good: the next call
// with the same key — every minute, from the mesh's export — finishes the rest.
func TestANewOperatorKeyFailingPartwayIsFinished(t *testing.T) {
v := newVault(t, nil)
for _, name := range []string{"a/one", "b/two", "c/three"} {
if _, err := v.Mint(name, "the test"); err != nil {
t.Fatal(err)
}
}
pub, priv := operatorKey(t)
v.Store.failWrite = func(r Record) error {
if r.Name == "b/two" {
return os.ErrPermission
}
return nil
}
n, err := v.SetOperatorKey(asPEM(t, pub), "the test")
if err == nil || n != 2 {
t.Fatalf("a failure partway said %d sealed again, err %v", n, err)
}
if left, _ := v.StillSealedToAnother(); left != 1 {
t.Fatalf("%d copies left on the earlier key, not 1", left)
}
v.Store.failWrite = nil
// The path the minute takes while the export holds the key: the same key again.
if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 1 {
t.Fatalf("the same key again sealed %d again (%v), not the 1 left", n, err)
}
if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 0 {
t.Fatalf("the same key a third time sealed %d (%v)", n, err)
}
all, _ := v.Store.List()
for _, rec := range all {
if rec.Operator.Key != pub {
t.Errorf("%s is still sealed to an earlier key", rec.Name)
}
openOperator(t, pub, priv, rec.Operator.Sealed)
}
}
// Who was handed which secret, and when, and who was refused, is in the vault's own store.
func TestWhoWasHandedWhatIsRecordedInTheStore(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "the-value", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
if _, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey); err != nil {
t.Fatal(err)
}
_, _ = v.Get("ace.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
handed, err := v.Store.Handed(10)
if err != nil || len(handed) != 2 {
t.Fatalf("%v %v", handed, err)
}
if handed[1].To != "g14.node-provisioner" || handed[1].Outcome != "handed" || handed[1].Generation != 1 || handed[1].At.IsZero() {
t.Errorf("the hand-out is recorded as %+v", handed[1])
}
if handed[0].To != "ace.node-provisioner" || handed[0].Outcome != "refused" || handed[0].Why == "" {
t.Errorf("the refusal is recorded as %+v", handed[0])
}
raw, _ := os.ReadFile(filepath.Join(v.dir, "store", "handed.jsonl"))
if strings.Contains(string(raw), "the-value") {
t.Error("the record of hand-outs holds a value")
}
}
// The vault's operator copies reach the operator's recovery: secret_vault_export is a document
// `secret recover --from-export` reads, found by node "vault", module "mesh-vault" and the secret's name,
// and opened with the operator's private key as that command opens it (mesh-controller
// cmd/mesh-controller/secret.go keptFromExport, internal/secrets Open).
func TestTheOperatorRecoversAVaultSecretFromItsExport(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "recover-me", "the test"); err != nil {
t.Fatal(err)
}
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
file := filepath.Join(t.TempDir(), "vault-export.json")
raw, _ := json.Marshal(answer)
_ = os.WriteFile(file, raw, 0o600)
// What `secret recover vault mesh-vault pair/postgres/gitea --from-export <file>` reads.
var e struct {
Kept []struct {
Node, Module, Name, Sealed, Key string
} `json:"kept"`
}
read, _ := os.ReadFile(file)
if err := json.Unmarshal(read, &e); err != nil {
t.Fatal(err)
}
var sealed string
for _, k := range e.Kept {
if k.Node == "vault" && k.Module == "mesh-vault" && k.Name == "pair/postgres/gitea" {
sealed = k.Sealed
if k.Key != v.operatorPublic {
t.Errorf("the export names key %s, not the operator's", k.Key)
}
}
}
if sealed == "" {
t.Fatalf("the export holds no copy of the secret: %s", read)
}
// secrets.Open: the public half derived from the private key, then the anonymous box.
privRaw, _ := base64.StdEncoding.DecodeString(v.operatorPrivate)
key, _ := ecdh.X25519().NewPrivateKey(privRaw)
if got := openOperator(t, base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()), v.operatorPrivate, sealed); got != "recover-me" {
t.Fatalf("recovered %q", got)
}
if strings.Contains(string(read), "recover-me") {
t.Error("the export holds the value in the clear")
}
}
// With no key in the export the minute calls Reseal, which finishes what is pending too.
func TestResealFinishesWhatIsPending(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Mint("a/one", "the test"); err != nil {
t.Fatal(err)
}
pub, _ := operatorKey(t)
v.Store.failWrite = func(Record) error { return os.ErrPermission }
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err == nil {
t.Fatal("a failing write was not said")
}
v.Store.failWrite = nil
if n, err := v.Reseal(); err != nil || n != 1 {
t.Fatalf("%d %v", n, err)
}
}
// A secret is not handed out when who was handed it cannot be recorded.
func TestNoHandOutWithoutItsRecord(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(v.dir, "store", "handed.jsonl"), 0o700); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
if a, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey); err == nil {
t.Fatalf("handed out with no record of it: %+v", a)
}
}
// What an asker cannot shape fills no record: a name that is no secret's name, a caller that is no bus user.
func TestAnArbitraryAskFillsNoRecord(t *testing.T) {
v := newVault(t, nil)
asker, _ := seal.GenerateKeyPair()
for _, ask := range [][2]string{{"g14.x", strings.Repeat("junk ", 1000)}, {"g14.x", "../../etc"},
{"not a bus user!", "pair/a"}, {strings.Repeat("a", 300), "pair/a"}} {
_, _ = v.Get(ask[0], ask[1], asker.PublicKey)
}
if h, _ := v.Store.Handed(10); len(h) != 0 {
t.Errorf("recorded %d arbitrary asks: %+v", len(h), h)
}
}
// The record of hand-outs is bounded: rolled at its size into a fixed number of files, the oldest dropped.
func TestTheRecordOfHandOutsIsBounded(t *testing.T) {
v := newVault(t, nil) // refuses everyone, as the running vault does
v.Store.HandedMax = 2000
asker, _ := seal.GenerateKeyPair()
for i := 0; i < 500; i++ {
_, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey)
}
var total int64
files, _ := filepath.Glob(filepath.Join(v.dir, "store", "handed.jsonl*"))
if len(files) != HandedKept+1 {
t.Errorf("%d files of the record, not %d: %v", len(files), HandedKept+1, files)
}
for _, f := range files {
info, _ := os.Stat(f)
if info.Size() > 2000 {
t.Errorf("%s is %d bytes, over its bound", f, info.Size())
}
total += info.Size()
}
if total > int64(HandedKept+1)*2000 {
t.Errorf("the record holds %d bytes", total)
}
if v.Store.Rolled() == 0 {
t.Error("rolling was not counted")
}
if h, _ := v.Store.Handed(5); len(h) != 5 || h[0].To != "g14.flood" {
t.Errorf("the newest are not read back: %+v", h)
}
}
+10
View File
@@ -0,0 +1,10 @@
module mesh-vault
go 1.25.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
golang.org/x/crypto v0.50.0
)
require golang.org/x/sys v0.43.0 // indirect
+6
View File
@@ -0,0 +1,6 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-31
View File
@@ -1,31 +0,0 @@
// mesh-vault's events entrypoint, loaded by the per-node tool host (the provisioner runs in the same
// process — ADR 0052). The lifecycle events are EMITTED from the provisioner, where custody
// actually changes (novox/hq ADR 0041/0042):
// mesh-vault.provisioned — a consumer was granted a secret
// mesh-vault.rotated — that consumer's value changed (`rotate secret`)
// mesh-vault.deprovisioned — the consumer went away and its secret was withdrawn
// Here the vault reacts to them, keeping a lightweight audit line of who holds what and when it
// moved — the audit an owner of secrets is best placed to log. Fingerprints, never values.
import { on } from "@novox/mesh-sdk/events";
interface SecretEvent {
as: string;
consumer?: string;
fingerprint?: string;
rotations?: number;
}
await on<SecretEvent>("provisioned", async (e) => {
console.log(`[mesh-vault] secret provisioned for ${e.body?.as} on ${e.body?.consumer} (${e.body?.fingerprint})`);
});
await on<SecretEvent>("rotated", async (e) => {
console.log(`[mesh-vault] secret rotated for ${e.body?.as} — rotation ${e.body?.rotations} (${e.body?.fingerprint})`);
});
await on<SecretEvent>("deprovisioned", async (e) => {
console.log(`[mesh-vault] secret withdrawn from ${e.body?.as}`);
});
console.log("[mesh-vault] auditing secret lifecycle events");
+36 -20
View File
@@ -10,18 +10,10 @@
}
}
],
"capabilities": [
"container-runtime"
],
"emits": [
"provisioned",
"rotated",
"deprovisioned"
],
"consumes": [
"mesh-vault.provisioned",
"mesh-vault.rotated",
"mesh-vault.deprovisioned"
"secret-changed"
],
"receives": {
"secret": "${dir:grants}/mesh.json"
@@ -49,6 +41,12 @@
"path": "${dir:root}",
"class": "valuable",
"why": "the vault's root material"
},
{
"id": "store",
"path": "${dir:store}",
"class": "valuable",
"why": "every secret the vault holds, sealed to its own key and to the operator key"
}
],
"consumers": {
@@ -80,6 +78,11 @@
"id": "root",
"type": "directory",
"mode": "0700"
},
{
"id": "store",
"type": "directory",
"mode": "0700"
}
],
"build": {
@@ -87,29 +90,42 @@
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"language": "go",
"system": "arch",
"from": "cmd/mesh-vault",
"binary": "mesh-vault",
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
"mesh-vault"
],
"env": {
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "${dir:root}"
"MESH_VAULT_ROOT": "${dir:root}",
"MESH_VAULT_STORE": "${dir:store}",
"MESH_VAULT_STATE": "${dir:state}"
}
}
]
},
"tools": [
"secret_export",
"secret_holders",
"secret_verify",
"secret_handed",
"secret_vault_export",
"provisioner_retirement",
"provisioner_retire_approve",
"provisioner_retire_reject",
"provisioner_delete",
"vault_status"
],
"claims": [
{
"name": "mesh-vault",
"scope": "mesh"
"scope": "mesh",
"serves": [
"get"
]
}
]
}
-17
View File
@@ -1,17 +0,0 @@
{
"name": "@novox/module-mesh-vault",
"version": "0.1.0",
"description": "mesh-vault — provides the mesh `secret` interface: a module's own secret as an ordinary pair credential, held, audited and rotated like any other (novox/hq ADR 0085). Its ledger, provisioner, tools and events live here (ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-48
View File
@@ -1,48 +0,0 @@
// mesh-vault's provisioner — the adapter that makes vault a provider of the mesh `secret` interface. The
// reconcile loop, the contributions file, and reading the mesh's minted value are the sdk harness's;
// this writes only the per-service half (novox/hq ADR 0039/0040/0048) — and for a vault that half is
// taking custody, not creating anything.
//
// The `secret` interface (ADR 0085, design 24): a consumer requires a value for its own use — the
// password of a store it runs privately, an internal token — and reads it from the file the mesh
// writes on its machine. There is no server to create a login on. **The value is the pair
// credential itself**: the controller minted it, sealed it to both nodes, and delivered each its
// copy. What makes it *owned* is this: the vault records who holds it and its fingerprint, notices
// when `rotate secret` delivers a different one, and says so on the mesh. Rotation is not new
// machinery — it is the machinery that already moves a database password, pointed at a secret the
// vault provides (design 13).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { Ledger } from "../client.js";
const ledger = Ledger.fromEnv();
/** Emit a lifecycle event without letting a broker hiccup fail the custody itself. */
async function announce(type: string, body: Record<string, string | number>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:secret] emit ${type} failed: ${err}`);
}
}
runProvisioner("secret", {
async create(p: Provision): Promise<void> {
const { held, outcome } = ledger.record(p.as, p.consumer ?? "", p.password);
if (outcome === "unchanged") return; // the harness re-runs create on restart; nothing happened
console.log(`[mesh-vault] ${outcome}: ${held.as} (${held.fingerprint.slice(0, 19)}…, rotations ${held.rotations})`);
await announce(`module.mesh-vault.secret.${outcome === "granted" ? "provisioned" : "rotated"}`, {
consumer: held.consumer,
as: held.as,
fingerprint: held.fingerprint,
rotations: held.rotations,
});
},
async remove(p: { as: string }): Promise<void> {
if (!ledger.withdraw(p.as)) return;
console.log(`[mesh-vault] withdrawn: ${p.as}`);
await announce("deprovisioned", { as: p.as });
},
});
-80
View File
@@ -1,80 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Ledger, fingerprint, contributions, deliveredFingerprint } from "../client.ts";
function fresh(): Ledger {
return new Ledger(mkdtempSync(join(tmpdir(), "vault-ledger-")));
}
test("a first delivery is a grant, the same value again is nothing, a new value is a rotation", () => {
const ledger = fresh();
const t0 = new Date("2026-09-20T10:00:00Z");
const t1 = new Date("2026-09-21T10:00:00Z");
const granted = ledger.record("anchor-redis", "anchor", "first-value", t0);
assert.equal(granted.outcome, "granted");
assert.equal(granted.held.rotations, 0);
assert.equal(granted.held.since, t0.toISOString());
assert.equal(granted.held.fingerprint, fingerprint("first-value"));
assert.equal(ledger.record("anchor-redis", "anchor", "first-value", t1).outcome, "unchanged");
assert.equal(ledger.get("anchor-redis")!.rotations, 0, "an unchanged delivery counted as a rotation");
const rotated = ledger.record("anchor-redis", "anchor", "second-value", t1);
assert.equal(rotated.outcome, "rotated");
assert.equal(rotated.held.rotations, 1);
assert.equal(rotated.held.since, t0.toISOString(), "a rotation reset the grant date");
assert.equal(rotated.held.changed, t1.toISOString());
assert.equal(rotated.held.fingerprint, fingerprint("second-value"));
assert.deepEqual(rotated.held.history, [{ fingerprint: fingerprint("first-value"), until: t1.toISOString() }]);
});
test("the ledger holds fingerprints and never the value, in files nobody else can read", () => {
const dir = mkdtempSync(join(tmpdir(), "vault-ledger-"));
const ledger = new Ledger(dir);
ledger.record("anchor-redis", "anchor", "the-actual-password", new Date());
ledger.record("anchor-redis", "anchor", "the-rotated-password", new Date());
for (const name of readdirSync(dir)) {
const raw = readFileSync(join(dir, name), "utf8");
assert.doesNotMatch(raw, /the-actual-password|the-rotated-password/, `${name} holds a value`);
assert.equal(statSync(join(dir, name)).mode & 0o777, 0o600, `${name} is readable by others`);
}
});
test("withdrawing forgets a holder, and listing is by login", () => {
const ledger = fresh();
ledger.record("b-app", "b", "x", new Date());
ledger.record("a-app", "a", "y", new Date());
assert.deepEqual(ledger.list().map((h) => h.as), ["a-app", "b-app"]);
assert.equal(ledger.withdraw("a-app"), true);
assert.equal(ledger.withdraw("a-app"), false, "withdrawing twice said it found something");
assert.deepEqual(ledger.list().map((h) => h.as), ["b-app"]);
});
test("a login is a name, not a path", () => {
const ledger = fresh();
assert.throws(() => ledger.record("../etc/passwd", "n", "v"), /a login is a name/);
});
test("what the mesh delivers is read from the contributions file and fingerprinted, never returned", () => {
const dir = mkdtempSync(join(tmpdir(), "vault-grants-"));
const secret = join(dir, "anchor.redis.secret");
writeFileSync(secret, "minted-value\n"); // the host may leave a trailing newline; the value has none
const receives = join(dir, "mesh.json");
writeFileSync(receives, JSON.stringify({
requirement: "secret",
given: [
{ from: "redis", node: "anchor", as: "anchor-redis", secret },
{ from: "offer-only", node: "anchor" }, // a contribution with no login grants nothing
],
}));
const asked = contributions(receives);
assert.deepEqual(asked.map((c) => c.as), ["anchor-redis"]);
const seen = deliveredFingerprint(asked[0]);
assert.deepEqual(seen, { fingerprint: fingerprint("minted-value"), length: "minted-value".length });
assert.match(JSON.stringify(deliveredFingerprint({ as: "x", secret: join(dir, "missing") })), /not readable/);
});
-131
View File
@@ -1,131 +0,0 @@
// mesh-vault's tools — vault's own code (novox/hq ADR 0039), served through the sdk's tool harness. They
// return structured data about the secrets this vault provides, and **never a value**: a holder is
// identified by its login and a value by its fingerprint. That is the rule the source mesh's
// secret_locate / secret_verify were built on, after a secret printed into a transcript.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { Ledger, contributions, deliveredFingerprint, type Held } from "../client.js";
/** The mesh's export of every operator-sealed secret, as the mesh wrote it into the root dir. */
interface KeptExport {
export: number;
"operator-key": string;
fingerprint: string;
kept: { node: string; module: string; name: string; origin: string; sealed: string; key: string; "made-at": string }[];
unrecoverable?: { node: string; module: string; name: string }[];
}
export function getVaultTools(ledger: Ledger, receives: string | undefined, root: string | undefined): ToolDefinition[] {
return [
{
name: "secret_export",
description:
"The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can " +
"open a line of it; the operator, holding the private key off the mesh, recovers one with " +
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
"made before the mesh had an operator key.",
input: {
sealed: {
type: "boolean",
description: "include the sealed blobs (default true); false lists holders and the key only",
},
},
run: async (args) => {
if (!root) return { available: false, error: "this vault keeps no root secrets (MESH_VAULT_ROOT is not set)" };
let doc: KeptExport;
try {
doc = JSON.parse(readFileSync(join(root, "export.json"), "utf8")) as KeptExport;
} catch (err) {
return { available: false, error: `the mesh has not written an export here yet: ${err}` };
}
const withBlobs = args.sealed !== false;
return {
available: true,
export: doc.export,
"operator-key": doc["operator-key"],
fingerprint: doc.fingerprint,
count: doc.kept.length,
kept: doc.kept.map((k) => (withBlobs ? k : { node: k.node, module: k.module, name: k.name, origin: k.origin, "made-at": k["made-at"] })),
unrecoverable: doc.unrecoverable ?? [],
};
},
},
{
name: "secret_holders",
description:
"Who holds a secret from this vault: each consumer's login, node and module, when it was " +
"granted, how many times it has been rotated and when, and the fingerprint of the current " +
"value. Fingerprints only — the value is never returned.",
input: {},
run: async () => {
const asked = receives ? contributions(receives) : [];
const holders = ledger.list().map((h) => ({
...h,
module: asked.find((c) => c.as === h.as)?.from ?? null,
asked: asked.some((c) => c.as === h.as),
}));
return { holders, count: holders.length };
},
},
{
name: "secret_verify",
description:
"Check one holder's secret without seeing it: the fingerprint the vault recorded against " +
"the fingerprint of the value the mesh currently delivers here, and optionally against a " +
"fingerprint computed on the holder's own machine (sha256 of the file, as `sha256:<hex>`). " +
"Two ends agreeing proves they agree, not that either works — the login itself is the test.",
input: {
as: { type: "string", description: "the holder's login, e.g. anchor-redis" },
fingerprint: {
type: "string",
description: "optional: sha256:<hex> of the value as the holder reads it, computed there — never the value",
},
},
run: async (args) => {
const as = String(args.as ?? "");
const recorded = ledger.get(as);
if (!recorded) return { as, known: false, error: `this vault holds nothing for ${as}` };
const asked = receives ? contributions(receives).find((c) => c.as === as) : undefined;
const delivered = asked ? deliveredFingerprint(asked) : { error: "the mesh does not currently ask this vault to serve that login" };
const given = args.fingerprint ? String(args.fingerprint) : undefined;
return verdict(recorded, delivered, given);
},
},
];
}
function verdict(
recorded: Held,
delivered: { fingerprint: string; length: number } | { error: string },
given: string | undefined,
): Record<string, unknown> {
const deliveredMatches = "fingerprint" in delivered ? delivered.fingerprint === recorded.fingerprint : null;
const givenMatches = given === undefined ? null : given === recorded.fingerprint;
return {
as: recorded.as,
known: true,
recorded: recorded.fingerprint,
rotations: recorded.rotations,
changed: recorded.changed,
delivered: "fingerprint" in delivered ? delivered.fingerprint : null,
deliveredError: "error" in delivered ? delivered.error : null,
deliveredMatchesRecorded: deliveredMatches,
given: given ?? null,
givenMatchesRecorded: givenMatches,
givenIsAnEarlierValue: given === undefined ? null : recorded.history.some((h) => h.fingerprint === given),
ok: deliveredMatches !== false && givenMatches !== false,
};
}
// The tools exist only when the ledger can be reached from the environment; without it, vault
// contributes none rather than failing the whole tool runtime.
registerModuleTools("mesh-vault", (env) => {
try {
return getVaultTools(Ledger.fromEnv(env), env.MESH_RECEIVES, env.MESH_VAULT_ROOT);
} catch {
return [];
}
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
}
+17 -2
View File
@@ -129,8 +129,23 @@ operator (urgent at its bound, or its words now say "Needs you:"). An urgent con
**A message offers the answers of the ask about it, and Details** (`cmd/messenger/answers.go`). The router
calls no verb of a condition's: the controller asks for the answers a condition names, and performs the one
chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen. A
warning kept quiet is also said once after a day.
chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen, on a
condition's message. A warning kept quiet is also said once after a day.
**An ask's message reads at a glance** (hq issue 639). Its title is the asker's headline and its body the asker's
explanation alone — a summary, then at most three points (`asks.Glance`; an ask that does not hold to it is shown
and said in the log). Everything else is the message's note (`asks.Message.Note`), which a channel shows apart and
smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered;
and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers
and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to
the secret rule alone, as the whole words are. Answered, the message is one line —
"You chose Approve at 16:06 on Telegram · <headline>" — and the router's word on the asker is its note.
**An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation
twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they
are. The whole is shown only on a kind the controller issued as `verified-sender` and that was given the ask's
answers; everywhere else, and for every condition, the explanation under the content rule stands. A secret's
shape leaves the mesh nowhere: an ask whose whole words carry one is refused as a whole (`CheckSecret`).
| When | What |
|---|---|
@@ -367,8 +367,8 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
}
// The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372).
if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" ||
!strings.HasPrefix(edited.Body, "Told to mesh-delivery, which acts on it itself.\n") {
t.Errorf("%s still offers %q, or does not say how it ended: %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body)
edited.Body != "" || edited.Note != "Told to mesh-delivery, which acts on it itself." {
t.Errorf("%s still offers %q, or does not say how it ended in one line (issue 639): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
}
}
// The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question
@@ -625,7 +625,7 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
edited = m
}
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") ||
!strings.HasPrefix(edited.Body, "The delivery stays held.") {
edited.Body != "" || edited.Note != "The delivery stays held." {
t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited)
}
}
+58 -2
View File
@@ -163,6 +163,50 @@ func (h *Holder) openAsk(r *Record) *AskRecord {
return &rec
}
// wholeOf is an ask's explanation with its values whole (asks.Ask.Whole), where an ask's own message on one
// kind shows it in place of the explanation (novox/hq issue 383): on a kind that proves who answers by the
// controller's fresh word (Promises, as every other verified-sender judgement here), and that the router gave
// the ask's answers (a ticket there, so the kind was linked and root-free when the ask was taken). Empty where
// the kind proves nothing, the ask has no whole words, or the record is a condition's — a condition's words
// stay under the whole content rule, whatever an ask about it carries — then the explanation, held to that
// rule, is shown. The ask's state does not matter: a message edited to say how the ask ended still shows what
// was approved.
func (h *Holder) wholeOf(r *Record, kind string) string {
if a := h.provenAsk(r, kind); a != nil {
return a.Whole
}
return ""
}
// wholeDetailsOf is an ask's Details with their values whole (asks.Ask.WholeDetails), shown in the note in place of
// its Details where wholeOf would show its whole words: every value an approval sets, so the operator reads exactly
// what they approve (the review of mesh-controller #304). Empty elsewhere.
func (h *Holder) wholeDetailsOf(r *Record, kind string) string {
if a := h.provenAsk(r, kind); a != nil {
return a.WholeDetails
}
return ""
}
// provenAsk is the ask of an ask's own record when the kind proves who answers and was given the ask's answers;
// nil otherwise.
func (h *Holder) provenAsk(r *Record, kind string) *asks.Ask {
if r == nil || r.Origin != "ask" || r.Ask == "" || kind == "" || h.Bench == nil {
return nil
}
e := h.askOf(r.Ask)
if e == nil || !h.Bench.Promises(kind, "verified-sender") {
return nil
}
for _, tk := range e.rec.Tickets {
if tk.Kind == kind {
a := e.rec.Ask
return &a
}
}
return nil
}
// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for
// approve verifying their sender with an account of the operator's linked, where the controller says now
// that no agent can become root (rootFree, asked live).
@@ -439,6 +483,18 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
h.refuseAsk(asker, a, "its words carry "+refusal.String()+", which may not leave the mesh: move it into a reference or say it in words")
return nil
}
// An ask that does not read at a glance is said, and shown all the same: the bound is its asker's to keep
// (asks.Glance, issue 639), and refusing it would leave the operator unasked.
if err := asks.Glance(a.Explanation); err != nil {
h.Logf("[messenger] %s's ask %q is a wall of text on the phone: %v", asker, a.Headline, err)
}
// The whole words are shown only where the sender is proven (wholeOf), so a path or an address may stand
// in them; a secret's shape leaves the mesh nowhere, and an ask carrying one is refused as a whole, never
// shown with its explanation alone (novox/hq issue 383).
if refusal, ok := CheckSecret(a.Whole + "\n" + a.WholeDetails); !ok {
h.refuseAsk(asker, a, "its whole words carry "+refusal.String()+", which may not leave the mesh on any channel: withhold it")
return nil
}
open, replaced := 0, ""
h.mu.Lock()
for k, e := range h.asks {
@@ -1040,7 +1096,7 @@ func (h *Holder) showOnHandle(kind string, m Message) (string, error) {
if h.Publish == nil {
return "", errors.New("this router cannot reach the bus")
}
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
Secret: m.Secret}
if out.To == "" && h.Bench != nil && h.Bench.To != nil {
out.To = h.Bench.To(kind)
@@ -1065,7 +1121,7 @@ func (h *Holder) showOn(kind string, m Message) error {
if h.Publish == nil {
return errors.New("this router cannot reach the bus")
}
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
if h.Bench != nil && h.Bench.To != nil {
out.To = h.Bench.To(kind)
}
+58 -13
View File
@@ -65,6 +65,59 @@ func Check(text string) (Refusal, bool) {
return Refusal{"address", "a hardware address"}, false
case reIPv6.MatchString(text):
return Refusal{"address", "an IPv6 address"}, false
}
if refusal, ok := secretShape(text); !ok {
return refusal, false
}
if reWinPath.MatchString(text) {
return Refusal{"path", "a drive path"}, false
}
if refusal, ok := randomRun(text); !ok {
return refusal, false
}
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r)
}) {
w := strings.TrimRight(word, ".:!?")
if isPath(w) {
return Refusal{"path", "a file path"}, false
}
if isHostName(w) {
return Refusal{"address", "a host name"}, false
}
}
return Refusal{}, true
}
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one:
// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a
// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a
// bot token, a known token shape, a value given to a secret's name, a long hexadecimal string — hold whatever
// the run holds.
func CheckSecret(text string) (Refusal, bool) {
if refusal, ok := secretShape(text); !ok {
return refusal, false
}
return randomRunOutsidePaths(text)
}
// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes.
func randomRunOutsidePaths(text string) (Refusal, bool) {
for _, run := range reRun.FindAllString(text, -1) {
for _, piece := range strings.Split(run, "/") {
if len(piece) >= 20 && looksRandom(piece) {
return Refusal{"secret", "a long random-looking string"}, false
}
}
}
return Refusal{}, true
}
// secretShape is the secret shapes a pattern names.
func secretShape(text string) (Refusal, bool) {
switch {
case rePEM.MatchString(text):
return Refusal{"secret", "a key block"}, false
case reJWT.MatchString(text):
@@ -77,25 +130,17 @@ func Check(text string) (Refusal, bool) {
return Refusal{"secret", "a value given to a secret's name"}, false
case reHex.MatchString(text):
return Refusal{"secret", "a long hexadecimal string"}, false
case reWinPath.MatchString(text):
return Refusal{"path", "a drive path"}, false
}
return Refusal{}, true
}
// randomRun is a long unbroken run of characters spread as a random string's are.
func randomRun(text string) (Refusal, bool) {
for _, run := range reRun.FindAllString(text, -1) {
if looksRandom(run) {
return Refusal{"secret", "a long random-looking string"}, false
}
}
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r)
}) {
w := strings.TrimRight(word, ".:!?")
if isPath(w) {
return Refusal{"path", "a file path"}, false
}
if isHostName(w) {
return Refusal{"address", "a host name"}, false
}
}
return Refusal{}, true
}
@@ -122,8 +122,8 @@ func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) {
for _, m := range tg.edits {
edited = m
}
if !strings.HasPrefix(edited.Body, "Told to mesh-delivery as soon as the bus takes it") {
t.Errorf("the question claims its asker was told: %q", edited.Body)
if !strings.HasPrefix(edited.Note, "Told to mesh-delivery as soon as the bus takes it") {
t.Errorf("the question claims its asker was told: %q", edited.Note)
}
})
}
@@ -0,0 +1,90 @@
package main
import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note; once answered
// it is one line with the headline.
// decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form).
func decisionAsk(c *clock) asks.Ask {
return asks.Ask{ID: "kb-decision-343-0d1e2f3a", Headline: "Your agents' memory follows you to every machine",
Explanation: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " +
"are screened, though not perfectly.\n" +
"• Only memory folders travel: links and files over 128 KiB stay where they are\n" +
"• A conflict reaches you as a warning that needs you\n" +
"• A deleted file is marked, and a newer change elsewhere outlives the deletion",
Who: asks.Operator, Expires: c.now().Add(23*time.Hour + 50*time.Minute), OnExpiry: "the record stays proposed; nothing of it is in force",
About: "knowledge.decision.343",
Details: "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
"Read its 13 clauses with the knowledge base's get, number 343.",
Options: []asks.Option{
{ID: "approve", Label: "Approve", Does: "the record is accepted and its clauses are in force", Level: asks.Approve, Binds: bound("approve")},
{ID: "decline", Label: "Decline", Does: "the record stays proposed; nothing of it is in force", Level: asks.Approve, Binds: bound("decline")},
}}
}
func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
a := decisionAsk(c)
submit(t, h, "mesh-knowledge", a)
settle(h, c)
if len(tg.sends) != 1 {
t.Fatalf("telegram got %d messages", len(tg.sends))
}
m := tg.sends[0]
if m.Title != a.Headline || m.Body != a.Explanation {
t.Errorf("the body is not the asker's summary and points alone:\n%s\n%s", m.Title, m.Body)
}
note := strings.Split(m.Note, "\n")
asked := c.now().In(time.UTC).Format("15:04")
want := []string{
"Asked by mesh-knowledge at " + asked + ". Unanswered by tomorrow " + a.Expires.In(time.UTC).Format("15:04") +
": the record stays proposed; nothing of it is in force.",
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.",
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.",
"Read its 13 clauses with the knowledge base's get, number 343.",
}
if strings.Join(note, "\n") != strings.Join(want, "\n") {
t.Errorf("the note:\n%s\nwant:\n%s", m.Note, strings.Join(want, "\n"))
}
if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 {
t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text())
}
// Answered, it is one line with the headline; the body is not said again.
if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("Approve: %q %v", out, err)
}
var edited Message
for _, e := range tg.edits {
edited = e
}
if edited.Title != "You chose Approve at "+asked+" on Telegram · "+a.Headline || edited.Body != "" ||
strings.Contains(edited.Note, "What an agent learns") || len(edited.Actions) != 0 {
t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note)
}
}
// An ask that does not read at a glance is shown all the same, and said: refusing it would leave the operator
// unasked, and the bound is its asker's to keep.
func TestAnAskThatIsAWallOfTextIsShownAndSaid(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
var said []string
h.Logf = func(f string, a ...any) { said = append(said, f) }
a := decisionAsk(c)
a.Explanation += "\nA fourth point\nA fifth point"
submit(t, h, "mesh-knowledge", a)
settle(h, c)
if len(tg.sends) != 1 || !strings.Contains(strings.Join(said, "\n"), "is a wall of text on the phone") {
t.Errorf("sent %d, said %v", len(tg.sends), said)
}
}
+94 -23
View File
@@ -90,8 +90,11 @@ const (
// Message is what a channel shows: a title line and a body, and the answers it offers.
type Message struct {
Title string
Body string
Title string
Body string
// Note is the secondary words, after the body and the answers: who asked, what each answer does, the
// expiry, the asker's Details (asks.Message.Note, issue 639).
Note string
Urgent bool
Quiet bool // a clearing: shown without urgency
Actions []Choice
@@ -111,10 +114,13 @@ type Choice struct {
}
func (m Message) Text() string {
if m.Body == "" {
return m.Title
t := m.Title
for _, more := range []string{m.Body, m.Note} {
if more != "" {
t += "\n" + more
}
}
return m.Title + "\n" + m.Body
return t
}
// Channel is one way to the operator.
@@ -839,6 +845,16 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
if what == "cleared" || what == "silenced" || m.Title == "A mesh message was withheld" {
return m
}
// On a kind that proves who answers, an ask's explanation is shown with its values whole (issue 383): the
// path or the address the operator approves is readable there; a secret's shape still is not.
whole := h.wholeOf(r, kind)
if whole != "" {
if wm, ok := h.wholeWords(r, what, whole); ok {
m = wm
} else {
whole = ""
}
}
if r.Answered != "" {
m = h.answeredWords(r, m)
}
@@ -847,11 +863,13 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
}
withheld := map[string]bool{} // the labels of answers whose effect could not be said
if rec := h.openAsk(r); rec != nil {
// Who asks, as the bus proves it (the asker is the subject's, never the body's), what each answer does,
// and what happens if nobody answers (the review of 2026-10-09).
for _, line := range h.askLines(rec) {
// The note (issue 639): who asks, as the bus proves it (the asker is the subject's, never the body's), what
// each answer does, what happens if nobody answers (the review of 2026-10-09), where else it is answered,
// and the asker's Details. The body stays the asker's summary and points, read at a glance.
var note []string
for _, line := range h.askLines(rec, r) {
if _, ok := Check(line); ok {
m.Body = strings.TrimSpace(m.Body + "\n" + line)
note = append(note, line)
}
}
// **An answer whose effect is not said is not offered** (the confirmation review of 2026-10-09): its line
@@ -864,9 +882,25 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
}
if line := h.elsewhere(rec, kind); line != "" {
if _, ok := Check(line); ok {
m.Body = strings.TrimSpace(m.Body + "\n" + line)
note = append(note, line)
}
}
// On a kind that proves who answers, the Details whole (issue 383): every value an approval sets, held to
// the secret rule alone, as the whole words are.
details, passes := rec.Ask.Details, Check
if wd := h.wholeDetailsOf(r, kind); wd != "" {
details, passes = wd, CheckSecret
}
for _, line := range strings.Split(strings.TrimSpace(details), "\n") {
if line = strings.TrimSpace(line); line == "" {
continue
}
if refusal, ok := passes(line); !ok {
line = withheldLine(line, refusal, passes)
}
note = append(note, line)
}
m.Note = strings.Join(note, "\n")
}
for _, c := range h.choices(r, kind) {
if !withheld[c.Label] {
@@ -877,38 +911,58 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
}
// answeredWords is a message that says what came of its question. An ask's own message is the question
// itself, so its title becomes how it ended — "You chose Approve at 13:30 on Telegram · <headline>" — read at
// a glance on the phone, where the edit rings nothing (issue 372); the rest of what came of it, the
// explanation and when it was asked follow. A condition's message keeps its own title, and says it first.
// itself, so it becomes how it ended and what it was about — "You chose Approve at 13:30 on Telegram ·
// <headline>" — one line, read at a glance on the phone, where the edit rings nothing (issue 372); the body
// is not said again (issue 639), and what the router knows of the asker after it is the note. A condition's
// message keeps its own title, and says it first.
func (h *Holder) answeredWords(r *Record, m Message) Message {
if r.Origin == "ask" {
first, rest, _ := strings.Cut(r.Answered, "\n")
headline, explanation := wordsOf(r)
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Urgent: m.Urgent, Quiet: m.Quiet,
Body: strings.TrimSpace(rest + "\n" + explanation + "\nAsked " + when(r.Raised, h.Now(), h.zone()) + ".")}
headline, _ := wordsOf(r)
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Note: strings.TrimSpace(rest),
Urgent: m.Urgent, Quiet: m.Quiet}
if _, ok := Check(out.Text()); ok {
return out
}
// Its words refused (they are the asker's): the outcome is still said, before what may leave.
// Its headline refused (it is the asker's): the outcome is still said, before what may leave.
}
m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body)
return m
}
// askLines are what an ask's message says of the ask itself: who asks, each option's effect, and its expiry.
func (h *Holder) askLines(rec *AskRecord) []string {
// wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no
// secret's shape once composed; else false, and the message under the whole content rule stands.
func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) {
c := *r
c.Explanation = whole
m := compose(&c, what, h.Now(), h.zone(), 0)
if refusal, ok := CheckSecret(m.Text()); !ok {
h.Logf("[messenger] the whole words of %s carry %s; shown as on a channel that proves nothing", r.Key, refusal)
return Message{}, false
}
return m, true
}
// askLines are what an ask's message notes of the ask itself, in two lines: who asks and when, with what happens
// if nobody answers; then each option's effect.
func (h *Holder) askLines(rec *AskRecord, r *Record) []string {
asker := rec.Asker
if asker == SelfAsker {
asker = "the router itself"
}
lines := []string{"Asked by " + asker + "."}
first := "Asked by " + asker + " at " + when(r.Raised, h.Now(), h.zone()) + "."
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
first += " Unanswered by " + by(rec.Ask.Expires, h.Now(), h.zone()) + ": " + strings.TrimSuffix(rec.Ask.OnExpiry, ".") + "."
}
lines := []string{first}
var does []string
for _, o := range rec.Ask.Options {
if o.Does != "" {
lines = append(lines, o.Label+": "+o.Does+".")
does = append(does, o.Label+": "+strings.TrimSuffix(o.Does, ".")+".")
}
}
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
lines = append(lines, "If nobody answers by "+by(rec.Ask.Expires, h.Now(), h.zone())+": "+rec.Ask.OnExpiry+".")
if len(does) > 0 {
lines = append(lines, strings.Join(does, " "))
}
return lines
}
@@ -1092,3 +1146,20 @@ func roughly(d time.Duration) string {
}
return fmt.Sprintf("%d days", int(d.Hours()/24))
}
// withheldLine is a note line the rule refused, said withheld: a change's line ("+ key: value") keeps its key's name,
// so the operator knows which setting holds a value they are not shown (the review of mesh-controller #304); any
// other line, or one whose key the rule refuses too, is said withheld whole.
func withheldLine(line string, refusal Refusal, passes func(string) (Refusal, bool)) string {
if len(line) > 2 && strings.ContainsRune("+~-=", rune(line[0])) && line[1] == ' ' {
key := line[2:]
if i := strings.IndexAny(key, ": "); i > 0 {
key = key[:i]
}
named := line[:2] + key + " (a value carrying " + refusal.What + " is withheld here)"
if _, ok := passes(named); ok {
return named
}
}
return "(a line carrying " + refusal.What + " is withheld here)"
}
@@ -158,8 +158,8 @@ func TestAnAsksExpiryIsSaidWithItsDay(t *testing.T) {
rec := &AskRecord{Asker: ConditionOwner}
rec.Ask.Expires = now.Add(7 * 24 * time.Hour)
rec.Ask.OnExpiry = "nothing is done"
lines := strings.Join(h.askLines(rec), "\n")
if !strings.Contains(lines, "If nobody answers by Fri 16 Oct 21:33: nothing is done.") {
lines := strings.Join(h.askLines(rec, &Record{Raised: now}), "\n")
if !strings.Contains(lines, "Unanswered by Fri 16 Oct 21:33: nothing is done.") {
t.Fatalf("%s", lines)
}
}
@@ -208,11 +208,12 @@ func TestAnAsksMessageSaysWhoAsksWhatEachAnswerDoesAndWhatExpiryDoes(t *testing.
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
body := tg.sends[0].Body
for _, want := range []string{"Asked by mesh-delivery.", "Release: the delivery goes on.", "Stop: the delivery ends.",
// In the note, after the body and the buttons (issue 639): the body is the asker's own words alone.
body := tg.sends[0].Note
for _, want := range []string{"Asked by mesh-delivery at ", "Release: the delivery goes on. Stop: the delivery ends.",
"Silence for a week: nothing more is said of it for a week.",
"If nobody answers by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} {
if !strings.Contains(body, want) {
"Unanswered by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} {
if !strings.Contains(body, want) || strings.Contains(tg.sends[0].Body, want) {
t.Errorf("the message does not say %q:\n%s", want, body)
}
}
@@ -286,7 +286,7 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
}
// Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it
// dropped here, where the desk's own role sends).
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
Secret: m.Secret}
if c.Bench.To != nil {
out.To = c.Bench.To(kind)
@@ -0,0 +1,230 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// novox/hq issue 383: a settings proposal's values are what the operator approves, so where the sender is proven
// they are shown whole; where nothing is proven, the explanation under the whole content rule stands.
// proposalAsk is a proposal as the controller composes it: the explanation with its values masked, the whole
// with them whole, and Details with the fingerprint and where to read it, which the note shows.
func proposalAsk(c *clock, id string) asks.Ask {
a := heldAsk(c, id)
a.Headline = "Set mounts on shanks?"
a.Options = a.Options[:2]
a.Explanation = "+ shares: media=‹path›\n+ sources: recalbox=‹address›@‹path›\nProposed by an agent at 15:11."
a.Whole = "+ shares: media=/storage/media\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox\nProposed by an agent at 15:11."
a.Details = "Fingerprint 1234 5678 9abc def0 1234 5678.\nRead it whole: settings proposals " + id + " at the controller's terminal."
return a
}
func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *testing.T) {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-controller", proposalAsk(c, "s-1"))
settle(h, c)
if len(tg.sends) != 1 {
t.Fatalf("telegram got %d messages", len(tg.sends))
}
phone := tg.sends[0]
for _, want := range []string{"+ shares: media=/storage/media", "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "Proposed by an agent at 15:11."} {
if !strings.Contains(phone.Body, want) {
t.Errorf("the phone, which proves who answers, lacks %q:\n%s", want, phone.Body)
}
}
if strings.Contains(phone.Body, "‹") || strings.Contains(phone.Body, "Fingerprint") || strings.Contains(phone.Body, "settings proposals") {
t.Errorf("the phone shows a masked value, or the Details' words, in the body:\n%s", phone.Body)
}
// The Details are the note's, after what the answers do (issue 639): no answer of their own.
if !strings.Contains(phone.Note, "Fingerprint 1234 5678 9abc def0 1234 5678.") ||
!strings.Contains(phone.Note, "Read it whole: settings proposals s-1 at the controller's terminal.") {
t.Errorf("the phone's note lacks the Details:\n%s", phone.Note)
}
if phone.Title != "Set mounts on shanks?" || ticketFor(phone, "Release") == "" || ticketFor(phone, DetailsLabel) != "" {
t.Errorf("the phone's message: %q offering %s", phone.Title, labelsOf(phone))
}
// The desk proves nothing: the same record composed for it shows the explanation, its values masked.
r := h.open["ask.mesh-controller.s-1"]
if r == nil {
t.Fatal("no record of the ask")
}
desk := h.sayOn(r, "raised", "desktop")
if !strings.Contains(desk.Body, "+ shares: media=‹path›") || strings.Contains(desk.Text(), "/storage/media") || strings.Contains(desk.Text(), "nas.lan") {
t.Errorf("the desk shows a value whole:\n%s", desk.Text())
}
if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen {
t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State)
}
// Approved, the message is edited to one line that says so, with the headline; the body is not said again
// (issue 639).
if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("Release: %q %v", out, err)
}
edited, ok := tg.edits["1"]
if !ok {
t.Fatalf("the phone's message was not edited: %+v", tg.edits)
}
if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.HasSuffix(edited.Title, " · Set mounts on shanks?") ||
edited.Body != "" || len(edited.Actions) != 0 {
t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions)
}
}
// The mutation on the switch: the same ask without whole words is shown on the phone as on the desk, from its
// explanation; the whole words alone make the difference, and only where the sender is proven.
func TestAnAskWithoutWholeWordsIsShownFromItsExplanationEverywhere(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
a := proposalAsk(c, "s-2")
a.Whole, a.Details = "", ""
submit(t, h, "mesh-controller", a)
settle(h, c)
if len(tg.sends) != 1 || !strings.Contains(tg.sends[0].Body, "+ shares: media=‹path›") || strings.Contains(tg.sends[0].Body, "/storage/media") {
t.Fatalf("telegram: %+v", tg.sends)
}
if ticketFor(tg.sends[0], DetailsLabel) != "" || strings.Contains(tg.sends[0].Note, "Fingerprint") {
t.Errorf("an ask that says nothing for Details offers or notes it: %s\n%s", labelsOf(tg.sends[0]), tg.sends[0].Note)
}
// A kind that proves nothing never shows the whole words, whatever the ask says.
a = proposalAsk(c, "s-3")
submit(t, h, "mesh-controller", a)
settle(h, c)
if whole := h.wholeOf(h.open["ask.mesh-controller.s-3"], "desktop"); whole != "" {
t.Errorf("the desk is given the whole words: %q", whole)
}
if whole := h.wholeOf(h.open["ask.mesh-controller.s-3"], "telegram"); whole != a.Whole {
t.Errorf("the phone is not given the whole words: %q", whole)
}
}
// A secret's shape leaves the mesh nowhere: an ask whose whole words carry one is refused as a whole, so the
// phone never sees it, with or without the proven sender.
func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) {
// A path is read as a path, whatever its case and digits; a random run outside one is still a secret.
for text, may := range map[string]bool{
"+ shares: photos=/mnt/Photos_2024/Jochen": true,
"+ sources: games=smb://nas.lan/Recalbox_Games2024": true,
"+ library: /srv/media/Series_Archive/Season01": true,
"+ key: xK9mQ2vL8pR4tW7yB3nF6hJ1dG5sA0zC": false,
} {
if _, ok := CheckSecret(text); ok != may {
t.Errorf("CheckSecret(%q) lets it leave: %v", text, ok)
}
}
// A condition's record never shows an ask's whole words, whichever kind: its words are its own.
{
h, _, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
k := cond("delivery.hq.held", Warning, "the delivery of hq is held")
k.Raised = c.now()
h.Condition(EventRaised, k)
settle(h, c)
a := proposalAsk(c, "s-5")
a.About = "delivery.hq.held"
submit(t, h, "mesh-controller", a)
settle(h, c)
if r := h.open["delivery.hq.held"]; r == nil || r.Ask != "mesh-controller.s-5" || h.wholeOf(r, "telegram") != "" {
t.Errorf("a condition's record is given an ask's whole words")
}
}
for _, secret := range []string{
"+ token: ghp_abcdefghijklmnopqrstuvwxyz0123456789",
"+ password=hunter2",
"+ key: 0123456789abcdef0123456789abcdef",
"-----BEGIN PRIVATE KEY-----",
} {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
a := proposalAsk(c, "s-4")
a.Whole = "+ shares: media=/storage/media\n" + secret
submit(t, h, "mesh-controller", a)
settle(h, c)
got := w.of("mesh-controller")
if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") {
t.Errorf("%q: sent %d, told %+v", secret, len(tg.sends), got)
}
if _, kept := store.asks["mesh-controller.s-4"]; kept {
t.Errorf("%q: the refused ask was recorded", secret)
}
}
}
// The review of mesh-controller #304: a proposal of five keys, one of them 300 characters long, shows two keys at
// a glance; every key and every value, whole, is in the note on a kind that proves who answers, so the operator can
// read exactly what Approve sets. The desk, which proves nothing, shows them masked.
func TestEveryKeyAndItsWholeValueIsInTheNoteWhereTheSenderIsProven(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
var long strings.Builder
long.WriteString("library=/storage")
for i := 0; long.Len() < 300; i++ {
long.WriteString(fmt.Sprintf("/album-%02d", i))
}
value := long.String()[:300]
keys := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value}
masked := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4", "shares": "library=‹path›"}
lines := func(values map[string]string) string {
var out []string
for _, k := range []string{"a", "b", "c", "d", "shares"} {
out = append(out, "+ "+k+": "+values[k])
}
return strings.Join(out, "\n")
}
a := proposalAsk(c, "s-9")
a.Explanation = "Changes 5 setting(s) of mounts on shanks.\n+ a: 1\n+ b: 2\nand 3 more, each whole below: c, d, shares"
a.Whole = ""
a.Details = "Fingerprint 1234 5678 9abc def0 1234 5678.\n" + lines(masked)
a.WholeDetails = "Fingerprint 1234 5678 9abc def0 1234 5678.\n" + lines(keys)
submit(t, h, "mesh-controller", a)
settle(h, c)
if len(tg.sends) != 1 {
t.Fatalf("telegram got %d messages", len(tg.sends))
}
phone := tg.sends[0]
for k, v := range keys {
if !strings.Contains(phone.Note, "+ "+k+": "+v) {
t.Errorf("the phone's note lacks %s whole:\n%s", k, phone.Note)
}
}
if strings.Contains(phone.Body, "/storage") || strings.Count(phone.Body, "\n") > 3 {
t.Errorf("the body is not the glance:\n%s", phone.Body)
}
desk := h.sayOn(h.open["ask.mesh-controller.s-9"], "raised", "desktop")
if strings.Contains(desk.Text(), "/storage") || !strings.Contains(desk.Note, "+ shares: library=‹path›") {
t.Errorf("the desk shows a value whole, or not every key:\n%s", desk.Note)
}
// A secret's shape in the whole details refuses the ask as a whole.
b := proposalAsk(c, "s-10")
b.WholeDetails = "+ token: ghp_abcdefghijklmnopqrstuvwxyz0123456789"
submit(t, h, "mesh-controller", b)
settle(h, c)
if _, kept := store.asks["mesh-controller.s-10"]; kept || len(tg.sends) != 1 {
t.Errorf("an ask whose whole details carry a secret was taken")
}
}
// A change's line the rule refuses keeps its key's name in the note (the review of mesh-controller #304): the
// operator knows which setting holds a value they are not shown. A line whose key the rule refuses too is said
// withheld whole.
func TestAWithheldChangeLineKeepsItsKey(t *testing.T) {
for line, want := range map[string]string{
"+ password: hunter2": "+ password (a value carrying a value given to a secret's name is withheld here)",
"~ shares: /mnt/media (was: x)": "~ shares (a value carrying a file path is withheld here)",
"Read it at /etc/x": "(a line carrying a file path is withheld here)",
"+ ghp_abcdefghijklmnopqrstuvwxyz0123456789: 1": "(a line carrying ",
} {
refusal, ok := Check(line)
if ok {
t.Fatalf("%q passes the content rule", line)
}
if got := withheldLine(line, refusal, Check); !strings.HasPrefix(got, want) {
t.Errorf("%q: %q, want %q", line, got, want)
}
}
}
+8 -1
View File
@@ -76,7 +76,14 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold
since += "."
}
body := since
if explanation != "" {
if r.Origin == "ask" && what != "reminder" && what != "quiet-reminder" {
// An ask's own message (issue 639): its body is the asker's summary and points; when it was asked is in
// its note, with who asked.
body = ""
}
if explanation != "" && body == "" {
body = explanation
} else if explanation != "" {
body = explanation + "\n" + since
}
return Message{Title: title, Body: body, Urgent: urgent}
+1 -1
View File
@@ -2,4 +2,4 @@ module messenger
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.10
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
+8 -2
View File
@@ -1,2 +1,8 @@
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg=
git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+1
View File
@@ -31,6 +31,7 @@ as the operator.
| | fixed here |
|---|---|
| D1 | up to 4096 characters arrive whole; longer is cut and the cut said |
| issues 383, 639 | shown formatted (HTML), never reworded: the title bold, the body as it is with a key line in monospace, the note apart in a blockquote collapsed to its first lines, kept whole when a message is too long (the body is cut first, and the cut said); `<`, `>` and `&` escaped first, so nothing in the words is read as markup. A tap is compared by the plain words, whitespace aside |
| D4 | a warning or a clearing arrives without sound; an urgent message or a question rings |
| D5 | a rate limit is waited out as told |
| D6 | a refusal is a permanent failure, said to the router and not retried; an edit to the same text is a success |
+6 -4
View File
@@ -249,9 +249,10 @@ func keyboard(buttons [][2]string) map[string]any {
return map[string]any{"inline_keyboard": rows}
}
// Send posts a message and answers its id. silent: it arrives without sound (D4).
// Send posts a message, its text in ParseMode (rendered or escaped by the caller), and answers its id. silent:
// it arrives without sound (D4).
func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (int64, error) {
body := map[string]any{"chat_id": chat, "text": text, "disable_notification": silent,
body := map[string]any{"chat_id": chat, "text": text, "parse_mode": ParseMode, "disable_notification": silent,
"link_preview_options": map[string]any{"is_disabled": true}}
if len(buttons) > 0 {
body["reply_markup"] = keyboard(buttons)
@@ -261,9 +262,10 @@ func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (i
return out.MessageID, err
}
// Edit replaces a message's text and buttons; none given, the buttons go.
// Edit replaces a message's text (in ParseMode, rendered or escaped by the caller) and buttons; none given, the
// buttons go.
func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error {
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text,
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text, "parse_mode": ParseMode,
"reply_markup": keyboard(buttons), "link_preview_options": map[string]any{"is_disabled": true}}, nil)
if notModified(err) {
return nil
+26 -28
View File
@@ -7,7 +7,9 @@ package main
//
// - **Work** comes from its kind's worker on the `channel` queue: `show` (a message with buttons, each
// carrying only the router's ticket), `send` (words), `edit` (a message shown before, by the router's
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). A warning or a
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). The words are shown
// as the router gave them, formatted (render.go): the title bold, a key line in monospace, the note in a
// collapsed blockquote after the body, escaped so that none of them is read as markup (issues 383, 639). A warning or a
// clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out
// as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same
// text is a success (D6). A message that cannot be delivered after three tries is said as failed. A
@@ -88,10 +90,18 @@ type Store interface {
Show(chat, id int64, digest string) error
}
// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — its line
// endings and outer spaces aside — so a message whose words another holder of the token changed is told
// from the one this channel showed (the review of 2026-10-09).
// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — how the
// whitespace between them runs aside, which Telegram may change around a blockquote (issue 639) — so a message
// whose words another holder of the token changed is told from the one this channel showed (the review of
// 2026-10-09).
func wordsDigest(words string) string {
sum := sha256.Sum256([]byte(strings.Join(strings.Fields(words), " ")))
return hex.EncodeToString(sum[:])
}
// linesDigest is the digest a message shown before issue 639 was kept under: its line endings and outer spaces
// aside. A tap on such a message is still compared by it.
func linesDigest(words string) string {
w := strings.TrimSpace(strings.ReplaceAll(words, "\r\n", "\n"))
sum := sha256.Sum256([]byte(w))
return hex.EncodeToString(sum[:])
@@ -155,20 +165,6 @@ func (c *Channel) init() {
}
}
// text is a message as the chat shows it, cut at MaxLength and the cut said (D1).
func text(m asks.Message) string {
t := m.Title
if m.Body != "" {
t += "\n" + m.Body
}
if utf8.RuneCountInString(t) <= MaxLength {
return t
}
runes := []rune(t)
keep := MaxLength - utf8.RuneCountInString(cutNote)
return string(runes[:keep]) + cutNote
}
// buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64
// bytes Telegram allows; one that does not is left out and said.
func (c *Channel) buttons(m asks.Message) [][2]string {
@@ -231,7 +227,7 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
c.fail(m.Handle, err, true)
return nil
}
words, buttons := text(m), c.buttons(m)
said, buttons := partsOf(m), c.buttons(m)
silent := m.Silent || (!m.Urgent && len(buttons) == 0)
// **A message said again under a handle already shown replaces it** (issue 373): a reminder or an
// escalation is one message per problem, not one more beside it. An edit here notifies nobody, so this one
@@ -253,15 +249,15 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
var id int64
switch {
case place != nil:
err = c.Bot.Edit(place.Chat, place.ID, words, buttons)
err = c.Bot.Edit(place.Chat, place.ID, said.html(), buttons)
if err == nil || notModified(err) {
c.keepShown(place.Chat, place.ID, words)
c.keepShown(place.Chat, place.ID, said.plain())
}
if gone(err) {
place, err = nil, c.newMessage(m.Handle, chat, words, buttons, silent, &id)
place, err = nil, c.newMessage(m.Handle, chat, said, buttons, silent, &id)
}
default:
err = c.newMessage(m.Handle, chat, words, buttons, silent, &id)
err = c.newMessage(m.Handle, chat, said, buttons, silent, &id)
}
if err == nil {
if earlier != nil {
@@ -297,13 +293,15 @@ func (c *Channel) replaced(p Placed) {
return
}
c.Logf("[telegram] an earlier message could not be deleted (%v); its buttons are taken away instead", err)
if err := c.Bot.Edit(p.Chat, p.ID, "Replaced by a newer message below.", nil); err != nil && !gone(err) {
if err := c.Bot.Edit(p.Chat, p.ID, escape("Replaced by a newer message below."), nil); err != nil && !gone(err) {
c.Logf("[telegram] an earlier message could not be marked replaced either: %v", err)
}
}
func (c *Channel) newMessage(handle string, chat int64, words string, buttons [][2]string, silent bool, id *int64) error {
got, err := c.Bot.Send(chat, words, buttons, silent)
// newMessage sends words, rendered (render.go), and keeps where they are and what they say, plain.
func (c *Channel) newMessage(handle string, chat int64, parts shown, buttons [][2]string, silent bool, id *int64) error {
words := parts.plain()
got, err := c.Bot.Send(chat, parts.html(), buttons, silent)
if err != nil {
return err
}
@@ -353,7 +351,7 @@ func (c *Channel) Handle(u Update) {
// Only on a message showing exactly what this channel put there: one whose words another holder of
// the token changed, or one it never showed, answers nothing (the review of 2026-10-09).
if want, err := c.Store.Shown(cb.Message.Chat.ID, cb.Message.MessageID); err != nil || want == "" ||
want != wordsDigest(cb.Message.Text) {
(want != wordsDigest(cb.Message.Text) && want != linesDigest(cb.Message.Text)) {
words := "That answer was not taken: this message does not show what the mesh put there. Nothing was done."
c.answerTap(cb.ID, words)
// Said on the phone alone, never in the chat: the tapper may be somebody the buttons reached, who is told
@@ -461,7 +459,7 @@ func (c *Channel) remember(id string, chat int64) {
}
func (c *Channel) reply(chat int64, words string) {
if _, err := c.Bot.Send(chat, words, nil, true); err != nil {
if _, err := c.Bot.Send(chat, escape(words), nil, true); err != nil {
c.Logf("[telegram] could not answer in the chat: %v", err)
}
}
+74 -3
View File
@@ -202,7 +202,7 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T)
}
b := sent[0].Body
kb, _ := json.Marshal(b["reply_markup"])
if b["chat_id"] != float64(42) || b["text"] != "Delivery of hq held\nNeeds you: release it." || b["disable_notification"] != false ||
if b["chat_id"] != float64(42) || b["text"] != "<b>Delivery of hq held</b>\nNeeds you: release it." || b["disable_notification"] != false ||
string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` {
t.Errorf("sent %v %s", b, kb)
}
@@ -230,12 +230,83 @@ func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
got := f.of("sendMessage")[0].Body["text"].(string)
// The bound is on the words, as Telegram's is on the text once its markup is read: the tags do not count.
got := strings.NewReplacer("<b>", "", "</b>", "").Replace(f.of("sendMessage")[0].Body["text"].(string))
got = strings.ReplaceAll(got, "&gt;", ">")
if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) {
t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:])
}
}
// A message is shown formatted, never reworded (issues 383 and 639): the title bold, a key line in monospace, the
// note apart in a collapsed blockquote, and every '<', '>' and '&' escaped, so a message carrying one neither breaks
// nor injects. The tap on it is still compared by the plain words, which is what Telegram gives back.
func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on <shanks> & co?",
Body: "Changes two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)",
Note: "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.",
To: "42", Buttons: []asks.Button{{Label: "Approve", Ticket: "tkt"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
sent := f.of("sendMessage")
want := "<b>Set mounts on &lt;shanks&gt; &amp; co?</b>\nChanges two settings.\n<code>+ shares: media=/storage/media</code>\n" +
"<code>~ sources: a=b (was: c)</code>\n\n<blockquote expandable>Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.</blockquote>"
if len(sent) != 1 || sent[0].Body["text"] != want || sent[0].Body["parse_mode"] != "HTML" {
t.Fatalf("sent %v", sent)
}
f.mu.Lock()
shown := f.next
f.mu.Unlock()
// Telegram gives the words back plain, the space around the blockquote as it pleases: the tap on them is taken.
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
Text: "Set mounts on <shanks> & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
t.Fatalf("the tap on the rendered message was not taken: %s", got)
}
// Words changed by another holder of the token are still told apart.
c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
Text: "Set mounts on <shanks> & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
t.Fatalf("a tap on changed words was taken: %s", got)
}
// The edit says what was chosen, one line with the headline, and is in the same mode.
subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?",
Note: "Told to mesh-controller, which acts on it itself.", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
ed := f.of("editMessageText")
if len(ed) != 1 || ed[0].Body["parse_mode"] != "HTML" ||
ed[0].Body["text"] != "<b>You chose Approve at 15:06 on Telegram · Set mounts on &lt;shanks&gt; &amp; co?</b>\n\n<blockquote expandable>Told to mesh-controller, which acts on it itself.</blockquote>" {
t.Errorf("edited %v", ed)
}
// A plain answer in the chat is escaped too.
c.reply(42, "a <b>tag</b> & more")
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "a &lt;b&gt;tag&lt;/b&gt; &amp; more" {
t.Errorf("a reply was not escaped: %v", last[len(last)-1].Body["text"])
}
}
// A message shown before issue 639 was kept under the digest of its lines: a tap on it is still taken.
func TestATapOnAMessageShownBeforeIsStillTaken(t *testing.T) {
c, _, s, st := newChannel(t, goodToken)
words := "Delivery held\nRelease or Stop."
if err := st.Show(42, 9, linesDigest(words)); err != nil {
t.Fatal(err)
}
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: 9, Chat: Chat{ID: 42, Type: "private"}, Text: words}}})
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
t.Fatalf("the tap on a message shown before was not taken: %s", got)
}
}
func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
c, f, s, st := newChannel(t, goodToken)
st.Place("m1", Placed{Chat: 42, ID: 7})
@@ -528,7 +599,7 @@ func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "You chose Release: recorded." {
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "<b>You chose Release: recorded.</b>" {
t.Errorf("the late word was not said in the chat: %v", sent)
}
// Telegram refuses an acknowledgement that came too late: the word goes to the chat instead.
+93
View File
@@ -0,0 +1,93 @@
package main
// How a message looks in the chat (novox/hq issues 383 and 639, the operator's words: "no bold text, no
// formatting", "too much text, no formatting, no summary"). The router's words are shown as given —
// `exact-render` — and formatted, never reworded:
//
// - the title in bold: the headline, or how an answered ask ended;
// - the body as it is, a line that is a key with its value (as a settings proposal lists them, "+ key: value")
// in monospace;
// - the note — who asked, what each answer does, the expiry, the asker's details — apart, after a blank line,
// in a blockquote Telegram shows collapsed to its first lines, expanded on a tap.
//
// Every character Telegram's HTML would read as markup is escaped first, so a message carrying '<' neither
// breaks nor injects. What the operator taps on is compared by its plain words (wordsDigest): Telegram gives a
// message's text back without its formatting, so the digest is over the words before they are rendered.
import (
"regexp"
"strings"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// ParseMode is the formatting the Bot API is told the text is in.
const ParseMode = "HTML"
var keyLine = regexp.MustCompile(`^[+~=-] `)
// escape is a text with the characters Telegram's HTML reads as markup written as entities.
func escape(s string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(s)
}
// shown is a message's three parts as the chat carries them.
type shown struct{ title, body, note string }
// partsOf is a message's parts, cut so that its plain words fit MaxLength and the cut said (D1). The body is cut
// first: the note carries what each answer does and every value an approval sets, so it is kept whole as long as
// the title and the note fit; only then is the note cut, and that said too. The bound is on the words, as
// Telegram's is on the text once its markup is read.
func partsOf(m asks.Message) shown {
s := shown{title: strings.TrimSpace(m.Title), body: strings.TrimSpace(m.Body), note: strings.TrimSpace(m.Note)}
over := utf8.RuneCountInString(s.plain()) - MaxLength
if over <= 0 {
return s
}
mark := utf8.RuneCountInString(cutNote)
if r := []rune(s.body); len(r) >= over+mark {
s.body = string(r[:len(r)-over-mark]) + cutNote
return s
}
// The body cannot make room alone: what is left of it is the cut's word, and the note is cut too.
s.body = strings.TrimPrefix(cutNote, "\n")
if over = utf8.RuneCountInString(s.plain()) - MaxLength; over > 0 {
r := []rune(s.note)
s.note = string(r[:max(len(r)-over-mark, 0)]) + cutNote
}
return s
}
// plain is the words as Telegram gives them back: the title, the body on the lines after it, the note after a
// blank line.
func (s shown) plain() string {
t := s.title
if s.body != "" {
t += "\n" + s.body
}
if s.note != "" {
t += "\n\n" + s.note
}
return t
}
// html is the same words as the chat shows them.
func (s shown) html() string {
var b strings.Builder
b.WriteString("<b>" + escape(s.title) + "</b>")
if s.body != "" {
for _, line := range strings.Split(s.body, "\n") {
b.WriteString("\n")
if keyLine.MatchString(line) {
b.WriteString("<code>" + escape(line) + "</code>")
} else {
b.WriteString(escape(line))
}
}
}
if s.note != "" {
b.WriteString("\n\n<blockquote expandable>" + escape(s.note) + "</blockquote>")
}
return b.String()
}
@@ -0,0 +1,86 @@
package main
import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// adr0343 is the ask for ADR 0343 as the router now sends it to this channel (novox/hq issue 639): the knowledge
// base's brief as its title and body, the rest in its note. On 2026-10-13 the same ask arrived as about thirty plain
// lines — every one of its thirteen clauses on a line of its own — and the operator would not read it.
var adr0343 = asks.Message{Handle: "h-343", To: "42",
Title: "Your agents' memory follows you to every machine",
Body: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " +
"are screened, though not perfectly.\n" +
"• Only memory folders travel: links and files over 128 KiB stay where they are\n" +
"• A conflict reaches you as a warning that needs you\n" +
"• A deleted file is marked, and a newer change elsewhere outlives the deletion",
Note: "Asked by mesh-knowledge at 16:05. Unanswered by tomorrow 15:55: the record stays proposed; nothing of it is in force.\n" +
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.\n" +
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
"Read its 13 clauses with the knowledge base's get, number 343.",
Buttons: []asks.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}}
// The ADR 0343 ask fits a phone's glance: a bold headline, the summary and its points, then the rest apart and
// collapsed — at most twelve lines in all, and never a clause.
func TestTheADR0343AskFitsAGlance(t *testing.T) {
c, f, _, _ := newChannel(t, goodToken)
subject, body := work(asks.Show, adr0343)
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
sent := f.of("sendMessage")
if len(sent) != 1 {
t.Fatalf("sent %d", len(sent))
}
html := sent[0].Body["text"].(string)
t.Logf("as sent:\n%s", html)
lines := strings.Split(html, "\n")
if len(lines) > 12 {
t.Errorf("the ask is %d lines, more than 12:\n%s", len(lines), html)
}
if lines[0] != "<b>Your agents&#39; memory follows you to every machine</b>" && lines[0] != "<b>Your agents' memory follows you to every machine</b>" {
t.Errorf("the headline is not first and bold: %q", lines[0])
}
if !strings.HasPrefix(lines[1], "What an agent learns") || strings.Count(html, "\n• ") != 3 {
t.Errorf("the summary and its three points do not follow the headline:\n%s", html)
}
if !strings.Contains(html, "\n\n<blockquote expandable>Asked by mesh-knowledge at 16:05.") || !strings.HasSuffix(html, "number 343.</blockquote>") {
t.Errorf("the note is not apart and collapsed:\n%s", html)
}
for _, lead := range []string{"Only the memory folders are carried", "How it is checked", "13 clause(s)", "**"} {
if strings.Contains(html, lead) {
t.Errorf("the ask carries %q", lead)
}
}
if sent[0].Body["parse_mode"] != "HTML" {
t.Errorf("not sent as HTML: %v", sent[0].Body["parse_mode"])
}
}
// The note is kept whole when the message is too long: the body is cut first and the cut said; only a note that
// cannot fit beside the title is cut, and that said too (the review of mesh-catalog #278).
func TestALongBodyIsCutBeforeTheNote(t *testing.T) {
m := asks.Message{Title: "t", Body: strings.Repeat("b", 5000), Note: "+ shares: media=/storage/media\nFingerprint 1234."}
s := partsOf(m)
if s.note != m.Note || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength {
t.Errorf("note %q, body ends %q, %d characters", s.note, s.body[len(s.body)-20:], len([]rune(s.plain())))
}
m.Body, m.Note = "the summary", strings.Repeat("n", 5000)
s = partsOf(m)
if !strings.HasSuffix(s.note, cutNote) || !strings.Contains(s.body, "cut here") || len([]rune(s.plain())) != MaxLength {
t.Errorf("body %q, note ends %q, %d characters", s.body, s.note[len(s.note)-20:], len([]rune(s.plain())))
}
}
// A '<', '>' or '&' in the note is escaped like any other part: it neither breaks the blockquote nor injects.
func TestTheNoteIsEscaped(t *testing.T) {
got := partsOf(asks.Message{Title: "t", Note: "Asked by <b>x</b> & y </blockquote><i>"}).html()
want := "<b>t</b>\n\n<blockquote expandable>Asked by &lt;b&gt;x&lt;/b&gt; &amp; y &lt;/blockquote&gt;&lt;i&gt;</blockquote>"
if got != want {
t.Errorf("%q", got)
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ module telegram
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.10
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
+6
View File
@@ -1,2 +1,8 @@
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+13
View File
@@ -0,0 +1,13 @@
{
"agent-managed-settings": {
"type": "object",
"description": "The managed settings every agent session on a node is held to (Claude Code's managed-settings.json), as a JSON object.",
"trusted": true
},
"font-size": {
"type": "number",
"description": "The size a desktop program shows its text in, in points.",
"minimum": 6,
"maximum": 72
}
}