Compare commits

...
Author SHA1 Message Date
jschoubben 53351dd30d Merge main into feat/47-G-join11: the zfs module and pacman's config first (issue 1979); zfs's findings in G's form
mesh/delivery merged: source.main moved to novox/mesh-catalog@6ba73d4e2a4f
mesh/delivery-group group feat/47-G-join12 merged: every member is merged
mesh/check checking 53351dd30df2 on b1125c02cfe6 | novox/mesh-catalog#675 in group feat/47-G-join11
mesh-conditions keeps G's form: the keeper reads no observer's state (no reads, no Observers list), so main's zfs entry there goes. The zfs follower says its findings through G's findings writer over its keeper: the keeper gains the publish of the writer's three events on the finding stream finding-events and the hear of mesh-conditions.findings-asked on its own durable, nothing else; each pool or device not ONLINE is scope module, id zfs.<pool>.<device> (the device's own name), kind not-online, on the node. The manifest asks its state findings by template in requires (G §529, §577), and gains the finding-events stream and the consumes, and the keeper's bus the three events and the consumes.

Builds: design 47 G §383, §384, §387, §529, §577, A-339, A-340, A-341, A-342; design 42 zfs-owns-zfs §27, A-32, A-37, A-39, A-41

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-13 09:32:48 +01:00
mesh-tools 32f1566cc8 Merge pull request 'zfs: the module owns a node's ZFS and changes nothing unset; pacman declares its config before its package (issue 1979)' (#676) from feature/1979-zfs-owns-zfs into main 2026-11-13 08:01:02 +00:00
jschoubben 0041b69d17 zfs: findings is a state of the module's state list, not of requires, so the conditions keeper reads it
mesh/delivery merged: source.main moved to novox/mesh-catalog@32f1566cc879
mesh/delivery-group group feature/1979-zfs-owns-zfs merged: every member is merged
mesh/check being carried to 0041b69d1736 on 32f1566cc879 | from 117f933dacf0, mesh-delivery 7221a1e5cd7f, mesh-host 0193949efe9f, mesh-sdk e42b9491c93…
The validator refused zfs and mesh-conditions: mesh-conditions reads zfs.findings, which zfs had moved to bus state in requires, where the new form has no readers (ADR 0596 section 2). The keeper process's bus.state keeps naming findings. The manifest test holds it.

Builds: design 42 zfs-owns-zfs A-46 (A-32, 26, 27)
2026-11-13 03:27:38 +01:00
jschoubben d1a15ced6c zfs: the keeper tests' socket directory is the shortest that works
In the test's temporary directory, or /tmp when the socket path would pass 100 characters, so a long TMPDIR cannot fail the bind.

Builds: design 42 zfs-owns-zfs §18, A-37
2026-11-13 03:15:30 +01:00
jschoubben afa825f902 mesh-hand-acts: the whole test dates its warrant from now, inside the 14-day window
Issue 1979, blocking: a date-bound test. The warrant dated 2026-10-29 fell out of the listing's 14 days on 2026-11-13, failing every mesh-catalog check.

Builds: design 42 zfs-owns-zfs (the group's check)
2026-11-13 03:15:19 +01:00
jschoubben 064ebc9b84 zfs: the zed-functions file names its notification services on purpose, with its reason
The group's check failed TestRulesBus/NoCatalogueManifestNamesAnInstallation: 11 values name an installation (the vendor sites in the package's zed-functions.sh, resources[13]).

Builds: design 42 zfs-owns-zfs §14, A-2
2026-11-13 03:13:39 +01:00
jschoubben 8ae4fabfbe zfs: the keeper tests make their socket directory in the test's own temporary directory
The group's check failed TestOnlyRootOpensTheKeepersSocketAndANeedlessKeeperIsTheBusDown and TestAKeeperThatDoesNotAnswerIsWarnedOnce: stat /var/tmp: no such file or directory.

Builds: design 42 zfs-owns-zfs §18, A-37
2026-11-13 03:12:40 +01:00
jschoubben aa0a472b29 mesh-conditions: the keeper reads zfs's findings (A-32)
The group's check failed TestEveryControllerKindHasItsHome: the reader zfs's findings are not read by the keeper.

Builds: design 42 zfs-owns-zfs §27, A-32
2026-11-13 03:12:09 +01:00
jschoubben 9787223509 Merge main into feat/47-G-join11 (G takes in main before its push)
mesh/delivery closed: a newer head of the same pull request
mesh/delivery-group group feat/47-G-join11 checking: 15 of 17 member(s) ready
mesh/check checking 978722350921 on 32f1566cc879 | novox/mesh-catalog#675 in group feat/47-G-join11
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-13 02:16:56 +01:00
jschoubben d15502f365 zfs: declare health on the four running services, README says hand acts answer started
mesh/delivery-group group feature/1979-zfs-owns-zfs rejected: composed together: the checker's verdict at the members' heads (03f2ebff6ed0): unit: mesh-conditi…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (9): unit: mesh-conditions/cmd/mesh-conditions.TestEveryControllerKindHasItsHome: the rea…; and 8 more | d15502f3650d on 117f933dacf0
Builds: design 42 zfs-owns-zfs §13, §24, lead answers A-45, A-43
2026-11-13 02:01:06 +01:00
jschoubben 58664eee60 zfs: the hand acts answer started at once and the manifest asks stream-publish, bus-state-calls and ledger-calls (A-43, A-44)
Removes streamHear, HandPoll, HandWait and the stream mark; keeps toolEmit.

Builds: design 42 zfs-owns-zfs §24, §25, lead answers A-43, A-44
2026-11-13 01:52:50 +01:00
jschoubben 1ff904f101 zfs: the tools say events with mesh/stream.publish and a hand act hears its event on the events stream (A-42, A-12)
The tools say every event of §25 (snapshot.taken.person, scrub.requested and the others) with mesh/stream.publish on the
stream events, never stdio.Emit; the hand acts' 30 s wait reads the mark (stream.fill) before the act starts, then
stream.read from the next sequence, for at most HandWait.

Builds: design 42 zfs-owns-zfs §25, §24, lead answers A-42, A-12
2026-11-13 01:50:27 +01:00
jschoubben 2d592c63c1 zfs: a keeper that does not answer is warned once, and RunKeeper closes its second connection (review round 2 finding 2, A-37)
A failed keeper call in a root process (hand acts, forget, Paused, every call) now adds one warning that the bus is
taken as down, instead of being dropped silently. RunKeeper closes the connection acct.Dial opens when the keeper
stops; the message id's comment says it is nanotime-pid.

Builds: design 42 zfs-owns-zfs A-37
2026-11-13 01:45:19 +01:00
jschoubben 87f8c30a09 zfs: the keeper publishes events with an acknowledged JetStream publish on mesh.bus.zfs.events.<kind> (review finding 1, A-41)
Builds: design 42 zfs-owns-zfs A-41, §25
2026-11-13 01:31:08 +01:00
jschoubben e79392d666 zfs: a follower reaches the keeper's socket on every call, so a keeper up later is used (A-37, reviewer's note)
Builds: design 42 zfs-owns-zfs A-37
2026-11-13 01:29:15 +01:00
jschoubben 5f372ac63a zfs: read an unwritten ledger instance as state null and sequence 0, pass every error up (review finding 2, A-40)
Builds: design 42 zfs-owns-zfs A-40
2026-11-13 01:28:51 +01:00
jschoubben 008e02afbd zfs: take out the events stream's max-mib, a bound no design line gives (review finding 3)
Builds: design 42 zfs-owns-zfs §25
2026-11-13 01:28:51 +01:00
jschoubben 605c1b1ca0 Z19 the budget test does not match its own text
Builds: design 42 zfs-owns-zfs §31
2026-11-13 01:09:16 +01:00
jschoubben 7831f14a6e Z19 zfs-readme-budget: the README says what the module owns and how it is checked, and the test budget is a test
Builds: design 42 zfs-owns-zfs §31, §7, §19, §23
2026-11-13 01:06:04 +01:00
jschoubben 8927d68900 Z4 zfs-manifest-bus: the events stream, states and ledger by template, the keeper as the module's one process bus account
The three root processes declare no bus and reach the keeper's socket; the module invokes the service manager's start. The judge refuses the keeper's bus emits and state until the validator accepts what the module asks for in requires (A-38).

Builds: design 42 zfs-owns-zfs §25, §26, §27, §28, A-6, A-19, A-36, A-37, A-38
2026-11-13 01:05:19 +01:00
jschoubben ec27f12524 A-37 the keeper: the module's one process bus account, a socket only root opens, and the root processes' bus through it
Builds: design 42 zfs-owns-zfs §18, §20, §26, §27, A-6, A-20, A-37
2026-11-13 01:04:41 +01:00
jschoubben c6844fc951 Z11 Z12 Z15 the ledger is one instance per dataset and kind, its state a map taken keyed by the ZFS snapshot's name
Builds: design 42 zfs-owns-zfs §21, §22, §26, §28, A-36 (corrects A-30)
2026-11-13 01:03:17 +01:00
jschoubben 9116067e11 Z18 zfs-callsite: the call-site test pins every form of ADR 0679 whole
Renamed TestTheBundleRunsOnlyTheFormsOfADR0679: each root form is held to its whole argument list, no verb the decision never allows, zgenhostid only with the parsed hostid, a device only as cache or log.

Builds: design 42 zfs-owns-zfs §19
2026-11-13 01:01:35 +01:00
jschoubben 5ab8bb7e62 Z1 Z2 Z3 zfs-manifest: packages and values, files and units, the apply and the run in the declared order
The manifest declares zfs-utils and zfs-dkms, renames scrub-pool to pool, and declares every new value not managed until set; its config file is ${config-json}, the options file, ZED's folder, rc, functions, handlers and drop-in each under their condition, the three ZFS units, the apply (run-once, restarted by its config file) and the run before the distribution's scrub and trim timers. One commit: the three subtasks change the one file module.json.

Builds: design 42 zfs-owns-zfs §6, §7, §8, §9, §11, §12, §13, §14, §15, §18, §20, A-1, A-2, A-3, A-7, A-14, A-15, A-16, A-23
2026-11-13 01:00:42 +01:00
jschoubben 97bdb7d656 Z16 zfs-health: a refused or failed command in the applied state is unhealthy naming its key, and the module's own warnings
Builds: design 42 zfs-owns-zfs §29, §15, A-35
2026-11-13 00:55:17 +01:00
jschoubben c0067e77fc Z15 zfs-tools: zfs_config, zfs_snapshots_schedule, zfs_module_events, zfs_snapshots_check and zfs_findings read what the module owns, keeps and says
zfs_config also says what the module says before it leaves a node (Z17): the next load of the kernel module uses ZFS's defaults, the three units and /etc/hostid stay.

Builds: design 42 zfs-owns-zfs §28, §30, §10, A-31, A-35
2026-11-13 00:54:06 +01:00
jschoubben 8aad282e6c Z10 Z12 trim without day or time refused, keep count 0 removes nothing
A trim schedule of weekly or monthly with no trim-day or trim-time is one refused value naming it; a keep count of 0 is that kind's none: the existing scheduled ZFS snapshots are kept and a warning names them.

Builds: design 42 zfs-owns-zfs §20, §21, §22, A-33, A-35
2026-11-13 00:51:11 +01:00
jschoubben dd1a4e083c Z14 zfs-follower: the follower starts the apply on the events that end a wait and keeps its findings in its own state
Builds: design 42 zfs-owns-zfs §18, §27, A-10, A-16, A-32
2026-11-13 00:49:01 +01:00
jschoubben 23256413de Z12 zfs-snapshots-remove: the run removes surplus scheduled ZFS snapshots only by their own record
Builds: design 42 zfs-owns-zfs §22, §26, A-30, A-31
2026-11-13 00:44:07 +01:00
jschoubben a357a85faa Z11 zfs-snapshots-take: the run takes its due ZFS snapshots per filesystem and records each in the ledger
Builds: design 42 zfs-owns-zfs §21, §26, A-19, A-20, A-30, A-31
2026-11-13 00:43:09 +01:00
jschoubben 316039ca95 Z13 zfs-hand-acts: hand acts write their key and start the run; zfs_snapshot keeps a person's snapshots apart
Builds: design 42 zfs-owns-zfs §23, §24, A-12, A-17, A-18
2026-11-13 00:41:21 +01:00
jschoubben 06d83912cd Z10 zfs-run: the scheduled run takes the lock, starts a due scrub and trim without waiting, and acts on hand acts
Builds: design 42 zfs-owns-zfs §20, §24, A-17
2026-11-13 00:38:17 +01:00
jschoubben 1bea6edcb8 Z9 zfs-apply-run: the apply holds the lock, waits on a scrub or an unimported pool, and converges the node's id, ARC and pool
Builds: design 42 zfs-owns-zfs §15, §18, A-8, A-16, A-29
2026-11-13 00:34:58 +01:00
jschoubben 2fb1e5c4ea Z8 zfs-apply-devices: cache and log devices converged by their stable names, after their checks
Builds: design 42 zfs-owns-zfs §17
2026-11-13 00:32:06 +01:00
jschoubben a10bf45ed9 Z7 zfs-apply-properties: pool and dataset properties converged, read back and said
Builds: design 42 zfs-owns-zfs §10, §16
2026-11-13 00:27:31 +01:00
jschoubben 0fa4b5d8e7 Z6 zfs-events-state: each change, refusal and wait said as its event and kept in the state applied
Builds: design 42 zfs-owns-zfs §25, §26, A-19
2026-11-13 00:26:32 +01:00
jschoubben 3cb2b83a15 Z5 zfs-config-parse: the config file's values each read in their fixed form
A value of another form is refused before anything is written or run; absent and empty are not managed.

Builds: design 42 zfs-owns-zfs §9, §11, A-15, A-23
2026-11-13 00:25:48 +01:00
jschoubben 2b699d4046 Z0 pacman-config-first: the pacman module declares its config file before its package
The apply stops at the first failure and the package fails while the config file is missing (A-26).

Builds: design 42 zfs-owns-zfs §1, A-26
2026-11-13 00:23:56 +01:00
mesh-tools 117f933dac Merge pull request 'zfs: the scheduled run names its minutes, so its timer starts (issue 1979)' (#674) from fix/1979-scrub-schedule-list into main 2026-11-12 22:12:30 +00:00
jschoubben bb96fbbb3c zfs: the scheduled run names its minutes, so its timer starts (issue 1979)
mesh/delivery merged: source.main moved to novox/mesh-catalog@117f933dacf0
mesh/check checking bb96fbbb3c45 on 117f933dacf0
The node-engine writes a cron step (*/15) as a calendar systemd refuses, so
zfs-mesh-scrub.timer never started and the pool had no scrub schedule. A list
of minutes is written as *-*-* *:0,15,30,45:00, which systemd takes.

Builds: design 42 zfs-owns-zfs §18. Tests: 1979/AC1.
2026-11-12 23:07:55 +01:00
jschoubben 75d98dd908 C4: the build modules ask the steps state with its objects store in requires; outputs goes
mesh/delivery-group group feat/47-G-join11 checking: 7 of 7 member(s) ready
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 75d98dd9083a on 117f933dacf0 | novox/mesh-catalog#675 in group feat/47-G-join11
Builds: design 47 G A-254, A-313, A-314, §58, §547

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 22:29:28 +01:00
jschoubben f6eb5d9efa C2: the Go build module reads a compose request's manifests from the catalog entry of the fingerprint it names
Builds: design 47 G §615, §506, A-310

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 22:26:01 +01:00
jschoubben f112011727 C3: the forge module reads failed-steps' fill through the bundle's state call mesh/state.info
Builds: design 47 G §623, A-214

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 22:19:49 +01:00
jschoubben 0aa5d6637c C1: the gate and the forge hold no branch name; the trunk is the asked branch, a default is the repository's
Builds: design 47 G §428, §507, A-93

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 22:18:25 +01:00
mesh-tools e271757db7 Merge pull request 'kernel: declare amd-ucode only (issue 1977, L10)' (#673) from fix/1977-amd-only into main 2026-11-12 20:51:32 +00:00
jschoubben a6ce8dcbd6 restic's tool bundle sets no MESH_BROKER_FILE: the node's runtime sets it for a tool bundle
The validator refused the manifest: a bundle's env may not set what the runtime gives every tool bundle. The processes
night and awake keep theirs.

Builds: ADR 0192

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 21:49:32 +01:00
jschoubben c100e16ba0 kernel: declare amd-ucode only (issue 1977, L10)
mesh/delivery merged: source.main moved to novox/mesh-catalog@e271757db78b
mesh/check checking c100e16ba0af on e271757db78b
intel-ucode failed an apply on a stale package database and stopped the delivery; an Intel machine
fails the unit by the image check and gets intel-ucode in the change that adds it. Tests: 1977/AC1
and the pinned order string adjusted.

Builds: L10 (issue 1977 note 6)
2026-11-12 21:49:30 +01:00
mesh-tools b9abfde492 Merge pull request 'kernel: microcode in every initramfs (issue 1977)' (#672) from fix/1977-microcode into main 2026-11-12 20:35:16 +00:00
jschoubben f7616c6a9b kernel: the initramfs script takes over a default-config preset and checks each image (issue 1977, L7-L9)
mesh/delivery merged: source.main moved to novox/mesh-catalog@b9abfde492b2
mesh/check checking f7616c6a9bd4 on b9abfde492b2
Before mkinitcpio -P, a preset naming ALL_config="/etc/mkinitcpio.conf" is copied to the state's
taken-over/ and loses that line (another ALL_config is left and named); after it, an image without
kernel/x86/microcode/ in its first 64 KiB fails the unit and records nothing. The unit may write
/etc/mkinitcpio.d. The README names no node. Tests: 1977/AC7, 1977/AC8; AC3/AC4 fixtures write images.

Builds: L7, L8, L9 (issue 1977 note 4)
2026-11-12 21:31:05 +01:00
jschoubben 62c0eb90cb kernel: kernel_boot reads microcode from each initramfs image (issue 1977, L6)
Replaces the state record read (root's 0700) with images_with_microcode per /boot/initramfs-*.img:
true when the first 64 KiB hold a path under kernel/x86/microcode/; an unreadable image goes to
image_errors with its error. Test: 1977/AC5.

Builds: L6 (issue 1977 note 3)
2026-11-12 20:54:34 +01:00
jschoubben 5795f4d230 kernel: microcode in every initramfs through mkinitcpio's microcode hook (issue 1977)
Declares amd-ucode and intel-ucode, the drop-in /etc/mkinitcpio.conf.d/mesh-microcode.conf, the
initramfs script and mesh-kernel-initramfs.service; kernel_boot answers the running revision and
whether the images were built with the drop-in. Tests: 1977/AC1-AC5.

Builds: L1, L2, L3, L4, L5 (issue 1977 note 2)
2026-11-12 20:52:21 +01:00
mesh-tools a60728e6f0 Merge pull request 'systemd-resolved: the own resolver's file waits 3 s a try (issue 1748, ADR 0678)' (#671) from fix/1748-resolver-wait into main 2026-11-12 17:52:35 +00:00
mesh-tools 792a79720b Merge pull request 'mesh-vault: secret_holders lists every record of the vault's store by name and fingerprint (issue 1409)' (#670) from fix/1409-store-records-listed into main 2026-11-12 17:51:18 +00:00
jschoubben d638856768 Asker rename R2: the build modules' tests read a named ask's step under its asker
Builds: design 47 G §481, §483, A-163

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 18:37:29 +01:00
jschoubben d22a7f3a41 fix-1748-own-resolver-wait: the own resolver's file waits 3 s a try
mesh/delivery merged: source.main moved to novox/mesh-catalog@a60728e6f09d
mesh/delivery-group group fix/1748-resolver-wait merged: every member is merged
mesh/check checking d22a7f3a412f on a60728e6f09d | novox/mesh-catalog#671 in group fix/1748-resolver-wait
The resolver file of systemd-resolved (resolvers and kept facts) carries options timeout:3 attempts:2 edns0 in place of timeout:1; manifest test asserts both facts, guard test's literal follows.

Builds: design 50 resolver-wait §1, §4 (and §2 of the main file, line 73)
2026-11-12 18:09:17 +01:00
jschoubben ec646f55ee J26 item 2: the run-ask test expects a program's exit 7 to end its key failed
Since A-290 a run whose program exited other than 0 ends its key failed, the
reason saying the exit, its answer kept (steprun.finish); the test still
wanted passed. It now wants failed with "the program exited 7" and reads the
kept answer as before.

Builds: design 47 G A-290

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 18:06:06 +01:00
jschoubben 49ab6c00ea J26 item 1: build-node tests ask a stand-in registry, never the mesh's
Every TypeScript module of a fetch gets the registry's latest SDK (ADR 0573 §2,
A-284), so the harness's machine named the real registry and 11 tests (9 unit,
11 integration runs) reached the network; with the resolver down they failed.
The harness now serves a registry of the test's own holding one SDK release,
and the userconfig test reads its address from it.

Builds: design 47 G A-284, A-285

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 18:02:54 +01:00
jschoubben 45b0779b68 mesh-vault: a store file that is not a record is listed by name with its error (issue 1409, V10)
mesh/delivery merged: source.main moved to novox/mesh-catalog@792a79720b30
mesh/check checking 45b0779b6801 on 792a79720b30
Store.ListReadable lists every record and, beside them, each file it cannot read
by name with its error; RecordsHeld lists those as rows with an error and goes
on. recordsError stays for a store that cannot be listed at all. Store.List and
ListPrefix keep their behaviour for their other callers.

Builds: design 39 bus-and-switch §20; design 39 parts §12. Lead answers (issue 1409): V9 (keep recordsCount and recordsError), V10.
2026-11-12 17:41:39 +01:00
mesh-tools 0ec8347786 Merge pull request 'node-uplink links answers each link's mac, master and parent (issue 1965)' (#669) from fix/1965-links-mac into main 2026-11-12 16:35:36 +00:00
jschoubben 52572698cb mesh-vault: secret_holders lists every record of the vault's store by name and fingerprint (issue 1409)
S1 vault-licence-secrets-listed. secret_holders answers `records` and
`recordsCount` beside holders, count and enrolments: each store record's name,
generation, since, changed, changedBy/changedVia, origin, length and the vault's
own fingerprint of the whole value, plus key and part for licence.<key>.<part>.
A record the vault cannot open stays listed with an error. Never a value.

Builds: design 39 bus-and-switch §20, §15; design 39 parts §12 (D1); design 24 Every secret lives in the vault, Beyond generate and hold; as-is 06 The vault, as it runs.
Lead answers (issue 1409 note 11): V1, V-thorough, V2-V8. V3 drops the map's
tokenFingerprint and AC5; V-thorough widens the licence.-prefix list to every record.
2026-11-12 17:34:00 +01:00
jschoubben 700b37890b node-uplink links answers each link's mac, master and parent (S1a links-mac, issue 1965)
mesh/delivery merged: source.main moved to novox/mesh-catalog@0ec8347786f4
mesh/check checking 700b37890bfd on 0ec8347786f4
Both holders' uplink-tools copies read address, master, link and
link_index/link_netnsid from the existing ip -j address show call. Fields
are left out where a link has none (L3); a parent in another namespace is
{index, namespace} (L2); the form lives in uplink.go only (L4); only the
tool description changes, not seat.json (L5); bridge fdb MACs are not
answered (L6); names mac, master, parent (L1).

Builds: design 48 10, design 08 code:, issue 1965 note 11 L1-L6
2026-11-12 17:30:17 +01:00
jschoubben ea4bb70a21 Merge origin/main into the join: provisioner asks bounded, mesh-files fixes, step answer bound
Brings #664 (licences_switches carried entries), #665 (mesh-files lists a
place a secret is kept), #666 (provisioner: bounded retiring ask id, a
refused ask said, provisioner_health), #667 (mesh-build-go's step end
rewritten without the taken versions when over the steps state's bound),
#668 (mesh-files find writes the NUL itself). #667's buildstep/step.go and
digestshape_test.go conflict with G's deletion (the step runner moved to
mesh-delivery-sdk/steprun, SD5): G's side is kept; its intent, an answer over
the bound not lost, is G's A-290 (the answer kept as an output object).

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 17:01:44 +01:00
jschoubben 87ca34c92f A-291: the recovery keeper receives snapshot-get's chunks through the SDK's one helper
The keeper's hand-made receive (subscribe to the call's chunks, ask, receive in order, acknowledge each) moves into
mesh-sdk's process.Conn.CallChunks; the keeper's answer checks (an answered error, the space it fits, no chunk granted)
run as its accept hook before a chunk is taken.

Builds: design 47 G A-291

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 16:52:20 +01:00
mesh-tools 836fcd81f6 Merge pull request 'mesh-files: find writes the NUL itself, so a list or search through root runs at all (issue 1409)' (#668) from fix/1409-root-list-nul into main 2026-11-12 15:39:51 +00:00
mesh-tools 8484ab0032 Merge pull request 'A step answer over the steps state's bound is kept without its taken versions, said, not lost' (#667) from fix/step-answer-over-bound into main 2026-11-12 15:37:54 +00:00
jschoubben c29ef0d95e mesh-files: find writes the NUL itself, so a list or search through root runs at all (issue 1409)
mesh/delivery merged: source.main moved to novox/mesh-catalog@836fcd81f65b
mesh/check checking c29ef0d95ea7 on 836fcd81f65b
Live on novox 2026-11-12: node-files.list of a root-only directory answered
'fork/exec /usr/bin/sudo: invalid argument'. The kernel refuses a program
argument that holds a NUL byte, and listAsRoot's -printf format and
searchBelowAsRoot's ended in a literal \x00 since 5d98bcc0 (2026-10-16), so
no list or search through root ever ran on a node. find writes \0 itself.
The stand-in root in the tests refuses an argument holding a NUL, as exec
does, so every root call is held to it; it found the search's instance.

Builds: design 39 parts §15 and the lead's answer D3 (list through root of a place a secret is kept).
2026-11-12 16:34:24 +01:00
mesh-tools c0d666fdcf Merge pull request 'The provisioner's retiring asks get a bounded id, and a refused one is said (issue 1966)' (#666) from fix/1966-unpublished-placed-module into main 2026-11-12 15:34:21 +00:00
jschoubben 22bbb9c2c0 Keep a check's answer within the steps state: leave out the taken versions
mesh/delivery merged: source.main moved to novox/mesh-catalog@8484ab0032a5
mesh/check checking 22bbb9c2c09f on 8484ab0032a5
A catalog check's answer of 264712 bytes was refused by the steps state
(256 KiB per value) and its end written failed without it, so the checker
lost the biggest member's answer and could not preview it. When a passed
or failed end is refused as too large, finish writes it once more without
the fetch's taken versions (fetch.packages, which no reader judges), the
step's reason saying how many were left out, why, and that the module's
journal names each; still too large, it ends failed as before.

Quick fix for issue 1921 (note 91); the thorough fix is design 47 G A-290
(an answer over the bound kept as the object output.<ask>).

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 16:32:48 +01:00
mesh-tools 7b50628627 Merge pull request 'node-files lists a place a secret is kept, entries without content, and stat says a missing path is missing (issue 1409)' (#665) from fix/1409-list-secret-place into main 2026-11-12 15:28:22 +00:00
mesh-tools 23a280a664 Merge pull request 'licences_switches answers switches carried from the old database with their licence and consumer (issue 1409)' (#664) from fix/1409-carried-switches into main 2026-11-12 15:27:13 +00:00
jschoubben 3bb8ace833 U6, U7 provisioner: a bounded retiring ask id, a refused ask said, the Composition comment corrected
mesh/delivery merged: source.main moved to novox/mesh-catalog@c0d666fdcf55
mesh/delivery-group group fix/1966-unpublished-placed-module merged: every member is merged
mesh/check checking 3bb8ace833d1 on c0d666fdcf55 | novox/mesh-catalog#666 in group fix/1966-unpublished-placed-module
The retiring ask's id is made from the key's digest and the time (at most 64 characters),
so a long key is asked. An ask the router's rules refuse is said in the key's why and in
provisioner_health. The Composition comment says what the code does: an unpublished
module keeps only its own plan and keys.

Builds: design 47 fix-unpublished-placed-module U6, U7, ADR 0593 §8
2026-11-12 16:01:25 +01:00
jschoubben ab1eb0cb47 Review 1 of S1: the served-text row finds the list tool by its full name; list's text is the contract's word for word
mesh/delivery merged: source.main moved to novox/mesh-catalog@7b5062862758
mesh/delivery-group group fix/1409-list-secret-place merged: every member is merged
mesh/check checking ab1eb0cb4781 on 7b5062862758 | novox/mesh-catalog#665 in group fix/1409-list-secret-place
Builds: design 39 parts D3 (§15), bullets 2 and 4.
2026-11-12 15:52:34 +01:00
jschoubben 2a9827ce18 Review 1 of S1: list's served text ends with the account sentence only; a secret-shaped link target in a secret place is withheld
Issue 1409. readTools no longer appends the refusal sentence to list. A row in
TestSecretPlacesAreRefusedByNameBeforeAnyRead holds a link's target in a secret place to the content rule.
S2's match on sudo's English message is left as it is: the root path sets no locale, and sudo's environment
handling decides whether LC_ALL reaches stat.

Builds: design 39 parts D3 (§15), bullets 2 and 4
2026-11-12 15:50:45 +01:00
jschoubben 493c59c89b Join 11: merge mesh-catalog main (a check step that errors after its fetch merged still answers)
Main brought #663 (fff3b819) in mesh-build-go/buildstep/askrun.go; G moved that step
runner into mesh-delivery-sdk/steprun, where 5fbc366f holds the same answer on error,
so G's deletion of buildstep's askrun.go and its test is kept. The tree is G's.

Builds: design 47 G A-286

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 15:48:26 +01:00
jschoubben 1cb1c84cc1 A-287, A-288: gitea announces every state its manifest declares; its refusal test holds the meaning
KEPT gains failed-steps, idle and transfers, so each declared state can be read by
its tools; the force-push refusal's test holds that the refusal names the one tool
that may do it and that it runs on the operator's Approve of that call.

Builds: design 47 G A-287, A-288

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 15:45:03 +01:00
jschoubben fdd33e1234 S2 stat-says-missing: mesh-files stat says a path root finds missing does not exist
Issue 1409. A stat through root of a path that is not there answered that the file modes refuse it; it says
it does not exist, so design 39 parts §15's "gone" can be read.

Builds: design 39 parts D3 (§15), bullet 3
2026-11-12 15:44:51 +01:00
jschoubben c41387f271 S1 list-secret-place: mesh-files lists a place a secret is kept, entries only, marked secret-place
Issue 1409. A list of a place the mesh keeps a secret, or of a machine's credential places, at any depth,
answers each entry's name, type, owner, group, mode, size and a link's target held to the content rule,
never content, and says secret-place; a process's environ, mem, fd, map_files, root and cwd stay refused.
Lines touched cite ADR 0394 §7.

Builds: design 39 parts D3 (§15), bullets 2 and 5
2026-11-12 15:44:50 +01:00
jschoubben e034f73991 Join 11: gitea reads the step event's fingerprint by the seat's name
The seat mesh-pipeline-runner's step event carries `fingerprint`; gitea read
`fp`, which no event carries, and which the type-check refuses once the Node
fetch places the runner's contracts (design 47 G A-285).

Builds: design 47 G A-285

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 15:42:14 +01:00
jschoubben ee6d7c284d The carried-switch test names no real node or address (review round 1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@23a280a664be
mesh/check being carried to ee6d7c284da9 on 23a280a664be | from f1c04801e477
2026-11-12 15:40:10 +01:00
jschoubben 622b259145 Join 11: gitea names the pipeline runner's contracts and takes a revision answer whole
gitea imports @novox/mesh-delivery-contracts-mesh-pipeline-runner, so its
package.json names it and the Node fetch places the group's (design 47 G A-285);
the catalog view's revision answer is typed with its revision and entries, which
revisionAnswer already refuses loudly when the holder leaves them out.

Builds: design 47 G A-285

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 15:39:53 +01:00
jschoubben b5c5594ea7 licences_switches answers the old field names of carried entries (issue 1409)
Builds: design 39 bus-and-switch §37, §12 and the lead's answer C5 (hq 45ee2475).
2026-11-12 15:38:00 +01:00
jschoubben 64fa2f5abf A-285: the Node fetch packs each of the group's packages and places it by name
The fetch takes `group-packages` (a member's repository, commit, ref and the
package's directory there) in place of the single `package` and its tagged form;
it packs each package a module of the tree takes, publishing nothing, and places
it in every module whose package.json names it, the SDK in every module, in place
of the registry's whatever the lock names.

Builds: design 47 G A-284, A-285

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 15:37:30 +01:00
mesh-tools f1c04801e4 Merge pull request 'A check step that errors after its fetch merged still answers its fetch' (#663) from fix/checker-preview-after-failed-step into main 2026-11-12 14:34:05 +00:00
jschoubben fff3b819d3 A check step that errors after its fetch merged still answers its fetch
mesh/delivery merged: source.main moved to novox/mesh-catalog@f1c04801e477
mesh/delivery-group group fix/checker-preview-after-failed-step merged: every member is merged
mesh/check checking fff3b819d383 on f1c04801e477 | novox/mesh-catalog#663 in group fix/checker-preview-after-failed-step
A level that does not compile or generate ended the check step failed with no
result, so the checker could not tell a merged fetch from one that never
answered, and previewed and judged no manifest or composition of the member.
The step now ends failed with its answer all the same: its fetch and the
levels so far beside the error, which the step's end already keeps.

Builds: design 47 G A-286 (G-22-lead-answers-2.md at hq 41dd30fe; issue 1921; principle 3)
Quick: this checker on main; thorough: the same rule in G's checker (join 11, J15).

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 15:23:48 +01:00
jschoubben dad8672b73 Join 11: the fetch's proof loads both build tags and holds the tree's go.mod and go.sum to what they load
Try 2 of milestone G failed in go test, verifying nats-server against the checksum database: restic's
integration tests load it through testbus, neither its go.mod nor its go.sum named it, and the fetch's
shut-off load ran untagged, so it never met it. The second failure of this kind (after the join's
dropped zero-version requires), so the guard is held once, where every repository's check passes: each
directory of the checked tree is loaded untagged and with the integration level's tags, and its own
go.mod and go.sum must name every module the loads reach (a mesh module required, no sums; every other
required at the version selected, with both go.sum lines). The fetch fails naming the module and the
line. A sibling laid beside is loaded as before, unjudged.

This guard lands with G, so G's own check still runs today's proof; no harm, the gaps it finds in the
seven repositories are fixed in this join.

Builds: design 47 G (join 11, the check's shut-off build of the group's modules)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 15:21:36 +01:00
jschoubben b854f884e9 Join 11: restic requires and sums nats-server, which its tests load through testbus
The check builds with GOPROXY=off, so a module its packages load that go.sum does not hash is looked up in the
checksum database and fails (try 2: verifying nats-server v2.11.17). mesh-sdk testbus, which restic's tests import,
brings nats-server and its four requirements; go.mod and go.sum now name them, as go list -mod=readonly -deps -test
against the group's heads asks (both build tags).

Builds: design 47 G (join 11, the check's shut-off build of the group's modules)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 15:00:49 +01:00
jschoubben a57139dcf5 Join 11: claude-code's manifest test lists the operator channel's notify it invokes
Area 2 added seat:operator-channel.notify to the manifest's invokes for the call in main.go,
and the test of the manifest's invokes still listed the old ones, so the unit level failed.

Builds: design 47 G GR8

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 14:15:08 +01:00
jschoubben f7e59b876d Join 11: every go.mod requires, at the zero version, each mesh module its packages load
The checker's fetch pins only the zero-version requirements a directory's go.mod names
(mesh-build-go workspace.go goModOf), so a module reached only through another, such as
meshhandacts through meshhandactsclient or mesh-delivery-sdk through meshcatalogclient,
was never downloaded and the shut-off load failed (fetch.go:267). The join's merges had
dropped these indirect requirements that the trunk's go.mod files list.

Builds: design 47 G (join 11, the check's fetch of the group's modules)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 14:07:29 +01:00
jschoubben b1da6344a8 Join 11: merge origin/main (the licence manager in parts, issue 1409)
The conflict in the old cmd/claude-licence-manager/tools_test.go resolves to both sides: main
moved it to cmd/manager, and the join's count of binding.changed.<consumer> under its family
goes into the moved TestResyncSaysEachOnce. The whole-module test's runtime refused
binding.changed.<consumer> because the manifest names the family binding.changed: it now
accepts a key that extends a named family by further parts, as A-134 says the event.

Builds: design 47 G A-134

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 14:05:51 +01:00
jschoubben 0f796e6505 A-283: the bus module's keeper makes a state bucket with the max-mib its requirement declares
Builds: design 47 G A-283 (G-22 lead answers, with A-277 and ADR 0595)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 13:13:05 +01:00
mesh-tools 6d000647de Merge pull request 'The licence manager is built of its parts, a manager and its tools, and the finished carry-over leaves (issue 1409)' (#662) from feat/1409-parts into main 2026-11-12 12:05:46 +00:00
jschoubben cff1005422 P2: the licence manager is four parts of one manifest (issue 1409)
mesh/delivery merged: source.main moved to novox/mesh-catalog@6d000647de2d
mesh/check being carried to cff1005422c1 on 6d000647de2d | from f9dcbc295723
The one bundle that did everything becomes the manager bundle, which runs long and answers the seat's ten verbs and
the three acting tools, and a tools bundle of its own that answers the four reading tools and writes nothing, so they
answer while the manager fails to load or stops. Both import the module's one package for the layouts of the kept
data and the log entries, the key form, the entries' names and the settings with their validity; the settings file
and the account with its directory stay as they are.

Builds: design 39 parts §1, §2, §3, §4, §5, §6, §7, §8
2026-11-12 12:08:03 +01:00
jschoubben 0e9fb2cbe3 A-281: the switch key names the gate's own build through mesh-sdk's process, which the environment check allows 2026-11-12 12:03:25 +01:00
jschoubben 478551622d mesh-recovery consumes mesh-broker recreated and dropped, which its process binding hears (A-274)
Builds: design 47 G A-274

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 12:01:54 +01:00
jschoubben 742303895b §716: the gate keeps a group's members from the seat git's pull events in deliveries and groups; the switch runs once (A-281)
The gate consumes pull-updated and invokes open-pulls and pull; it writes deliveries and groups
under their key forms, deletes them as a take-in or a closed pull request leaves them, copies a
delivery's base onto the record's member, answers groups from records and the group key together,
puts a missed event right at its start, and writes switch.done once the switch has run.

Builds: design 47 G §716, A-279, A-236, A-281

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 12:01:29 +01:00
jschoubben ce9f984363 P1: the carry-over leaves the licence manager (issue 1409)
The carry-over from the store database ran live once and carried every row, so its code, the gate that answered
"the carry-over has not ended", the condition carry-over-failed, the tool licences_carried, the opening of grants
with grant.key and the postgres driver leave the module, with its requirement of the database and of the secret
grant-key. The state carried stays as the carry-over's record; at its start the manager waits for nothing and
clears a carry-over-failed it finds kept.

Builds: design 39 parts §6 (the template files/database.url), §9, §10, §11
2026-11-12 11:55:54 +01:00
jschoubben c82b40660b A-278 (join 11): mesh-recovery and nats pass mesh-sdk's seatlist.Definition to AnswerSeat
Builds: design 47 G A-278 (answers G-22)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 11:42:11 +01:00
jschoubben e436c59430 Area 5 (join 11): a take-in's branch no longer stands in for a member's base
Builds: design 47 G A-279 (answers G-22)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 11:40:07 +01:00
jschoubben fe56de746e Area 2 (join 11): claude-code invokes operator-channel notify and restic the assigner's assignments
Builds: design 47 G GR8 (per-verb grants), area 2 of the join

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 11:39:43 +01:00
jschoubben 8261c2c7e4 Area 5 (join 11): mesh-recovery fills a restore point's names when it takes the point
Builds: design 47 G A-230 (answers G-22)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 11:36:52 +01:00
jschoubben b276da8ba6 Area 5 (join 11): mesh-recovery's and restic's finding writers pass their finding-events binding
nats and distribution declare the old stream form and require no finding-events, so their writers stay on it.

Builds: design 47 G A-274 (answers G-22)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 11:34:26 +01:00
jschoubben f18e24ce19 Area 5 (join 11): the gate's take-in writes its entries into the record and each group member names its base
Builds: design 47 G A-188, A-236 (answers G-22)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 11:30:04 +01:00
jschoubben 466e1ae320 Merge remote-tracking branch 'origin/main' into feat/47-G-join11 2026-11-12 11:28:35 +01:00
jschoubben cc21062250 Area 4 (join 11): the Go build module lists its kept builds through buildstep's bus-free judgement
buildstep no longer takes mesh-sdk's buscalls objects (that package now reaches
the bus): the module judges each object of its kept-builds store with
KeptBuildOf and orders them with SortKept.

Builds: design 47 G (join 11 area 4: buildstep kept free of the bus)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 11:21:29 +01:00
mesh-tools f9dcbc2957 Merge pull request 'The licence manager counts room from the weekly window alone, so the switch finds a target (issue 1409)' (#661) from fix/1409-weekly-only into main 2026-11-12 10:18:53 +00:00
jschoubben a196bf6323 Area 6 (join 11): every AnswerSeat caller follows a seat list and its grant
AnswerSeat checks a call's caller against the seat's definition its caller
reads from the seat list it follows (A-186). The bus module's keeper hands
it the mesh-broker definition its seat list holds once heard; mesh-recovery,
which followed none, follows one over its connection and an event hub, read
again once its events are live, and its manifest and its process invoke
seat-definitions and consume the list's two events. Its whole test answers
the seat list.

Builds: design 47 G lead answers A-179, A-186 (join 11 area 6)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 11:15:02 +01:00
jschoubben 94a0ebbedf C4: a licence's room is its weekly window alone (issue 1409)
mesh/delivery merged: source.main moved to novox/mesh-catalog@f9dcbc295723
mesh/check checking 94a0ebbedfa6 on f9dcbc295723
The vendor retired the session limit and answers the session window empty, so Room required a percentage that never
comes: live, no licence had room and no switch could choose a target. Room is now 100 less the weekly percentage; the
session window takes no part in the switch threshold, a target's bound, the near-limit warning or no-room's words.
A reading with no weekly percentage still has unknown room. The settings' descriptions and the README say so.

The whole tests' vendor answers the session window empty, as it does live; on the old rule they failed with the live
symptom ("has no session or weekly window in its reading").

Builds: design 39 bus-and-switch C4
2026-11-12 10:57:19 +01:00
jschoubben e9cdf08841 Join 11: distribution's holder test is C3d's, which its holder code is
The merge of main's holder test (caller list from the config watch) over C3d's
(no caller list, REG2) left cases the merged holder does not answer; the
holder's code is C3d's, so its test is too, with the moved delivery paths.

Builds: design 47 G REG2 (C3d)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 10:55:34 +01:00
jschoubben 0be81ae036 Join 11: the merged heads compile where no area's fix is owed
The join's importers name the delivery repository's moved contracts and sdk
(MV1), node-tools' tests use mesh-sdk's gate, the duplicate node-engine claim
rule of the reader release gives way to G's, and the assigner names its first
watched branch, never a constant. The seat holders' AnswerSeat callers are
area 6's.

Builds: design 47 G join 11 (merge of fix round 11)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 10:41:44 +01:00
mesh-tools c5f6845386 Merge pull request 'The licence manager declares its logs in the logs section, so they are issued and its carry-over can finish (issue 1409)' (#660) from fix/1409-logs-section into main 2026-11-12 09:29:45 +00:00
jschoubben 2dc2c7e431 Merge branch 'feat/47-G-fix11-c3d' into feat/47-G-join11
# Conflicts:
#	modules/distribution/go.mod
#	modules/distribution/internal/holder/holder_test.go
2026-11-12 10:26:55 +01:00
jschoubben 9e005317dd Merge branch 'feat/47-G-fix11-c3c' into feat/47-G-join11
# Conflicts:
#	modules/nats/cmd/nats-tools/keep.go
#	modules/nats/go.mod
2026-11-12 10:26:43 +01:00
jschoubben 4226204e57 Merge branch 'feat/47-G-fix11-b7' into feat/47-G-join11 2026-11-12 10:25:58 +01:00
jschoubben 546b674d14 Merge branch 'feat/47-G-fix11-c1c' into feat/47-G-join11 2026-11-12 10:25:58 +01:00
jschoubben 4caea7e624 Merge remote-tracking branch 'origin/main' into feat/47-G-join11
# Conflicts:
#	modules/distribution/internal/holder/holder_test.go
2026-11-12 10:25:58 +01:00
jschoubben 636cb633ac C3: the manager's two logs in the logs section the runtime issues (issue 1409)
mesh/delivery merged: source.main moved to novox/mesh-catalog@c5f684538607
mesh/check checking 636cb633acfd on c5f684538607
Live after #659 the manager, in its own runtime, was still issued none of its logs: the runtime on main issues
template states but logs only from the `logs` section. usage (35 days, 256 MiB) and audit (64 MiB) move there; the
states stay templates. The whole tests' runtime now issues logs only from `logs`, as main does, and failed on the
old manifest.

Builds: design 39 bus-and-switch C3
2026-11-12 10:21:27 +01:00
mesh-tools 6181d9afb3 Merge pull request 'The licence manager runs as its own account, so its own logs are issued to it and its carry-over can finish (issue 1409)' (#659) from fix/1409-own-runtime into main 2026-11-12 08:41:49 +00:00
mesh-tools 89ad370485 Merge pull request 'systemd: serve kernel-log, the node's kernel log of this boot or an earlier one; journal refuses a boot (issue 1975)' (#658) from fix/1975-kernel-log into main 2026-11-12 08:40:36 +00:00
jschoubben 333e3c21a5 C1c-2b: the three build modules keep a run's answer in an outputs store and serve the verb output
Each module's puller keeps an answer over what step-ended carries inline in the object store `outputs` its manifest requires
(template objects, 256 MiB) and its tools serve `<seat>.output` from it; the manifest claims the verb.

Builds: design 47 G A-254, A-257

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 09:39:54 +01:00
jschoubben 8291502cbb C2: a key pair the manager cannot read is made anew in its place (issue 1409)
mesh/delivery merged: source.main moved to novox/mesh-catalog@6181d9afb3e5
mesh/check checking 8291502cbbaf on 6181d9afb3e5
Moved to its own account, the manager finds manager-key.json still owned by the tool runner's account, which the
node-engine does not hand over; it skipped making a pair because the file existed, and every verb answered the read
error. Now a pair it cannot read is replaced by a new one, renamed over it, and said in its journal; every use passes
the public key per ask, so nothing depends on the old pair.

Builds: design 39 bus-and-switch C2

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 09:38:03 +01:00
jschoubben 8d2497b65e C1: the licence manager runs as its own account, its service and its tools in its own runtime (issue 1409)
Run inside the machine's tool runner, the manager was issued none of its own logs and states, so the carry-over
failed live ("keeps no log called usage ... it was issued: none"). It now declares runs-as claude-licence-manager,
makes that account, owns what it places on its machine and its secrets, reaches the bus with its own broker secret,
and invokes a node's claude_code_grant, which the tool runner's account could ask and its own could not.

The whole tests play the manager's own runtime: they issue only the states, logs, asks and events its manifest
names, and fail on a manifest without runs-as; a unit test holds every ask in the code to the manifest's invokes.

Builds: design 39 bus-and-switch C1

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 09:25:57 +01:00
jschoubben 5b77eb18f8 B5g3-4: mesh-build-go takes a run's input of any size from the chunked verb input
Builds: design 47 G §712, §354, A-237, A-249

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 09:24:45 +01:00
jschoubben aa10f070f2 B7-2: record-opened carries a forge-made record's repository, pull request, commit and branch
OpenForgeMade is the fill; no path of the gate opens a forge-made record (design 47 G §151 ends the mark from G's delivery),
so the teller says none until one does.

Builds: design 47 G §170, §522, A-161, A-172
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:46:45 +01:00
jschoubben 9ee1cbe3f5 B7-1: recovery-merged names the branch the merge moved and the module of the record's catalog half
Builds: design 47 G §81, §522, A-161, A-172
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:46:13 +01:00
jschoubben b52dd7ca44 B7-3: the pinned event's catalog entries name their branch, the record keeps it
A catalog revision is pinned by its watched branch and its number (revisions are numbered per branch): PinnedRevision gains
branch, Pin and Free take it, the key names it so two branches' revision 9 are two pins, and pinsOf says {branch, revision}.
Also the C2 merge's sourcetools imports follow the contracts' new path.

Builds: design 47 G §579, A-171
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:45:53 +01:00
jschoubben bf60d6a8bb NATS3 (rest): the bus module plans from the catalog half of every watched branch, not from manifest-accepted
The keeper follows meshcatalogclient's branch follower and the seat list on its views reader, and plans again at each
move of a branch's view, the registrations or the seat list. Each local name is planned from the first of the module's
watched branches in its registration's order; another branch asking it with other settings is said drifted, naming
the branch. A module of the new form whose resolved seat definition has a verb or an event gets its sent store; one
including only parts, and a foundation part, none. A module in no watched branch's newest revision loses its plans
key, its entries orphans. The sum of the caps counts the old sections the entries declare. The manifest consumes the
catalog half's and the seat list's events and invokes their verbs; manifest-accepted and indexer_bus_state go.

Builds: design 47 G §344, §433, §532, §533, §537, §538, §547, §591

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:45:49 +01:00
jschoubben 584fc94baa BG2, BG3: the three build modules check the asker against the seat list and pull the named worker
The runtime source is *steprun.SeatAsks (named worker not bound is said through the module's journal); the callers follow
the seat list (steprun.SeatListCallers).

Builds: design 47 G §543, §544, A-163, A-170

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:44:46 +01:00
jschoubben 11f49905a1 Merge branch 'feat/47-G-fix11-c2' into feat/47-G-fix11-b7 2026-11-12 05:43:07 +01:00
jschoubben 51df346a23 BG12, BLD16: the Go build module's finding names the module as the runtime does
compose.go no longer fixes its own name "mesh-build-go": the finding's module is MESH_MODULE.

Builds: design 47 G §7, §368, §583

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:34:39 +01:00
jschoubben c1008aeedf BG4, BN4, BI4: the three build modules' pullers say the seat's events
Builds: design 47 G §496, §602, §603

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:33:26 +01:00
jschoubben 8d73b5ba1b C3d REG2: distribution keeps no caller list; the build modules are the catalog half's claimants of node-build-* seats
The config `callers`, the tool registry_callers and its invokes, the caller-list code and the holder's per-verb
Allowed checks go. The windows' driver counts as build modules the modules claiming a `node-build-*` seat in the
catalog half, followed by the seat mesh-catalog's branch follower on the program's views reader (one shared events
hub); it wakes again when a build module's entry moves. The retention check of remove finds the artifact sweep's
holder through the seat mesh-module-assigner's holders (seatlist.HolderOf), not a name.

Builds: design 47 G §610, §7, §428, A-156, A-82
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:30:54 +01:00
jschoubben 48a9402ca0 Merge feat/47-G-fix11-c1: the build modules say steprun, the catalog's buildstep stays gone
Builds: design 47 G §464, §680

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:29:50 +01:00
jschoubben ee56d56922 BKP4-BKP6 live path: restic's item events over the bus with the night's own broker account; the wait for a restore ends when the bound's expiry cancels it
An integration test starts the night as the node does: its credential is read from where the manifest places it (the process
night's MESH_BROKER_FILE, the module's own-secrets broker), it says each item started and ended on the seat node-backup, gives
way to a restore that runs (items put off as keys of the state put-off, started again on the restored) and, past put-off-bound,
the bus's expiry writes backup-waits into the findings. It found that WaitRestored held a fetch of a minute after its context
ended, so an expiry ended the wait up to a minute late: it now consumes the restored events and ends on the context.

Builds: design 47 G §563, §561, §554, A-150, A-155

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:22:20 +01:00
jschoubben af0d58fc6f gofmt: the alignments the step runner's rename moved
Builds: design 47 G §464 (the rename buildstep. to steprun. in five files of the build modules)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:22:00 +01:00
jschoubben fea509d3f5 BKP6: the put-off wait for a restore is a bound key of restic's state put-off; its expiry raises backup-waits and ends the wait
The record backed-up reads is the key `<module>.<item>` of the module's own state put-off (key-ttl), bounded by the setting
put-off-bound (minutes, default 60); the bus removing it is heard as the expiry, which writes the finding backup-waits through
the held writer over the module's findings and ends the wait, so the night never holds its lock without a bound. The file
put-off.json and its writes are gone. The manifest requires put-off, findings and finding-events and hears findings-asked.

Builds: design 47 G §561, §563, §557, §302, A-155, A-77

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:18:38 +01:00
jschoubben ff6a8928d6 SD5, BG11, BLD10: the build modules' shared code moves into the delivery SDK
All of mesh-build-go's buildstep Go module leaves mesh-catalog for
modules/mesh-delivery-sdk: the step run, fetch and release and the
regenerate-and-compare step as the package steprun, the CPU share as
steprun/buildcpu, the conformance kit as steprun/conformance, the seat
generator as seatgen with cmd/seatgen, and cmd/mesh-pack. The three
build modules and the builder's and checker's tests import the SDK and
keep no copy; the SDK requires nothing of mesh-catalog. Code the build
modules share is the delivery domain's SDK, not one module's package.
It carries the build-module round's two new files of that package
(dropdetected.go 247c422f, storedbuilds.go 904b8aed).

Builds: design 47 G §464, §680, §327, §420, §495

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:18:34 +01:00
jschoubben 9fe3f41ac0 AM-K2: the forge module's gitea_branch_protection_loosen alone removes a trunk's protection or lets a force-push onto it
The tool is marked in the manifest's needs-approval, so each call runs on the operator's Approve; the manifest
uses operator-channel; set_full keeps refusing a force-push onto a trunk and names the new tool; both clients
gain deleteBranchProtection.

Builds: design 46 approval mark §21 (milestone G)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:17:44 +01:00
jschoubben 48ad5ce947 BG8, BN8, BI2: each artifact carries the version the ask names
A Go program is linked with -X main.meshVersion beside main.builtFor, a Node program's package.json version is set, an
image is labelled org.opencontainers.image.version; a build with no version stamps none.

Builds: design 47 G §458, §573

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:15:43 +01:00
jschoubben f339ea5936 BG5, BN5, BG6, BN6 (toolchain part): toolchain-reported on the seat's subject, with the toolchain program's digest
The guard's toolchains value carries the sha256 of the go or node program found on the path (read again when the version
changes); toolchain-reported is said on the build seat's subject of the node at start and on each change of version,
recipe or digest; a build's ToolchainUsed names the digest. The legacy toolchain.reported emit and its stream go: nobody
consumes them.

Builds: design 47 G §492, §472, §466 (lead answer A-152)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:14:21 +01:00
jschoubben 64669575c8 mesh-recovery: the four bound settings take a fractional minimum of 0.01, so a whole test sets seconds
A minimum of 1 refused the fractions A-138 allows; 0 stays refused, as the reader refuses it.

Builds: design 47 G §557, §551, A-138

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:12:20 +01:00
jschoubben e4ef67adcd MV1: move contracts and sdk under modules/, importers follow
The delivery repository's contracts and SDK live under modules/ as
mesh-delivery-contracts and mesh-delivery-sdk, their Go module paths
renamed to match the folders (the TypeScript names already did), and
every importer in mesh-delivery, mesh-catalog, mesh-host, mesh-tools
and mesh-sdk follows in the same group: a design as it should be, one
path per module, checked by the group's check building every importer.

Builds: design 47 G §427, §88

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:09:56 +01:00
jschoubben e723d498e7 NATS1, NATS6: the bus module's whole test makes the plans state and restores a state's objects store with it
Builds: design 47 G §58, §538

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:08:17 +01:00
jschoubben a5e8bcca8a BKP4 (recovery side): gofmt the recovery module after the rename
Builds: design 47 G §556
2026-11-12 05:07:58 +01:00
jschoubben 4f7c0ba534 BKP8: backed-up lists an item put off for a restore with the restore's act and since when
Builds: design 47 G §302, §563, §664
2026-11-12 05:07:04 +01:00
jschoubben 00be4c5063 GIT17: the forge module deletes seen keys past 30 days on each step-ended and at its start; git_seen_expire runs it now
Builds: design 47 G §581, §585, §487 (TypeScript tests not run here: no installed dependencies)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:06:03 +01:00
jschoubben d708998a53 BKP4, BKP5: the backup holder says each item's start and end and gives way to a restore of the recovery module
Builds: design 47 G §554, §556, §563, A-150
2026-11-12 05:05:54 +01:00
jschoubben 2d5358b8b9 AM-K1: the zsh module uses operator-channel for its marked execute
Builds: design 46 7-approval-mark §21 (milestone G approval mark)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:05:08 +01:00
jschoubben 741239a506 EE-K1: the Stop hook advises mesh_wait on a wait's event and a reread after every answer, or a watcher, with no condition on a grant
Builds: design 47 E §20

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:04:58 +01:00
jschoubben c235abcb11 mesh-recovery: the four bound settings take a minimum above 0, as the reader refuses 0
Builds: design 47 G §557, §561, §681 (catalog review finding)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:04:06 +01:00
jschoubben dcaa229c2b mesh-issues: mesh-recovery is an owner of issues
Builds: design 47 G recovery module (catalog review finding: the owners test of every code module)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:03:35 +01:00
jschoubben 0e3580895d mesh-conditions: ListBound is asked again on the bus module's created or a node-engine's running, not on a timer
Builds: design 47 G §515, §663

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:03:13 +01:00
jschoubben 8d6b7fbbc9 A-112: the bus module's snapshots-cap and snapshot-keep-days are config values in a settings file the keeper reads at its start
Builds: design 47 G §633, §681, lead answer A-112

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:02:13 +01:00
jschoubben f8160922c9 RTR4: the router keeps a refused ask as a closed record, the oldest removed past refused-kept
Builds: design 47 G §570
2026-11-12 05:01:57 +01:00
jschoubben 904b8aedd4 BG7, BN7: a fetch naming builds reads each package member from the artifact store
Each member's build is read at /v2/<module>/<artifact>/blobs/<digest> through the required provision artifact-store
(bound to a file), checked against its digest and laid as the package it names: a Go module zip replaced in the temporary
modfile, an npm tarball in node_modules. A mismatch or a store that does not answer fails the step naming the member.

Builds: design 47 G §465, §373, §466 (lead answer A-151)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:00:48 +01:00
jschoubben 6277067465 NATS6: the bus module keeps its plans and plans again only when a module's entries differ; nats_plans reads them
Builds: design 47 G §538

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:00:34 +01:00
jschoubben 177115d6cd RDR6: mesh-config and records hear the seat git's pull-merged and pull-closed and ask seat git's pull
Builds: design 47 G §654
2026-11-12 05:00:18 +01:00
jschoubben 78b12be752 NATS5: the bus module reads the bus's storage limits, refuses a plan whose caps pass the account's and shows the sum in list
Builds: design 47 G §547, §495, §661

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 04:59:19 +01:00
jschoubben f17001d920 KB3: the overview hears git.pull-merged and the runner's group-ended, not the gate's group and merged
Builds: design 47 G §431
2026-11-12 04:59:17 +01:00
jschoubben 24778d24b6 NATS8: the bus module's ask lasts its level's bound and a refused ask ends its request with the router's words
Builds: design 47 G §570

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 04:56:56 +01:00
jschoubben 247c422fbf BG9, BN9, BI3: the build seats' steps with drop writes the hand-act log first
A drop of a kept detected-steps key records the act with the caller the bus proved, then deletes the key; a log that
refuses deletes nothing and is answered error; a drop without why is refused; fetch is asked unless drop.

Builds: design 47 G §463, §480

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 04:56:25 +01:00
jschoubben ea87dd80bb GATE19: the gate states its pins at each start, then says pins-stated
Builds: design 47 G §579, §652
2026-11-12 04:55:56 +01:00
jschoubben 0699741fab NATS1: the bus module plans each state's <state>-objects store (16 MiB, objects-max-mib) and says its binding
Builds: design 47 G §58, §91, §513, §591, §607, §549

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 04:55:47 +01:00
jschoubben 476fc0cc30 BKP4 (recovery side): spell the item-started stream constant itemStartedStream
Builds: design 47 G §556, §563
2026-11-12 04:55:19 +01:00
jschoubben 75514e05e2 GATE8: gate_source and gate_source_again, the gate's source revisions read and said again
Builds: design 47 G §83, §84, §93
2026-11-12 04:55:19 +01:00
jschoubben 15db34169e distribution: the holder's test follows the rule that a chunk 0 again starts a part afresh
mesh/delivery merged: source.main moved to novox/mesh-catalog@89ad37048520
mesh/delivery-group group fix/1975-kernel-log merged: every member is merged
mesh/check being carried to 15db34169e24 on 89ad37048520 | from d9f2b7bcfa07, mesh-contracts 29ec1c3df312
mesh-delivery's parts.Receiver.Take took a chunk 0 again on purpose (276412d7, design 51 Publishing, 1966/AC203-1,
AC203-2); the holder's test still expected it refused and failed on every run, on trunk too. The test now expects a
chunk out of sequence refused and a chunk 0 again taken.

Builds: design 51 Publishing, the bullet "The file travels in parts through the seat"; issue 1975's group check

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 03:04:00 +01:00
jschoubben 500c8c8cec Merge branch 'feat/47-G-fix9-recovery' into feat/47-G-join10 2026-11-12 02:39:22 +01:00
jschoubben 6c2d6605a5 mesh-recovery: its one whole test against the bus, and an act's end said once it is free
The whole test runs the process on its own account against a bus of its
own, every bound a setting of seconds, with a stand-in bus module for
snapshot-get, snapshot-restore and snapshot-restored: a hand take's log,
a restore waiting on the dump past its bound key, busy and the ask again,
the expiry on its bound key, a keeper's snapshot in three chunks, a
protected restore cancelled at its bound and its aside put back, one the
keeper answers neither, and a stop putting the aside back.

It found that an act said its ending event while it was still the running
one, so a caller told busy that asked again on hearing it was told busy
with the same act and heard nothing more: the act is now released and
written ended before its ending event, and the removal at a snapshot-kept
runs before its ended.

Builds: design 47 G §557, §554, §681, §562, §551, A-138, A-140, A-141, A-143

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:36:19 +01:00
jschoubben 033ff2540b Fix 10: the provisioner reads the catalog key codec from mesh-delivery's sdk
Builds: design 47 G §614, §670 (ADR 0625 §4; lead answer A-144)
2026-11-12 02:26:55 +01:00
jschoubben ebcc7761f1 journal refuses a boot argument it does not use (issue 1975, AC4); tests cite their acceptance labels
mesh/delivery-group group fix/1975-kernel-log rejected: composed together: the checker's verdict at the members' heads (c265ee221ccf): unit: distribution/inter…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (2): unit: distribution/internal/holder.TestTheHolderOfTheTwoImageSeats/a_transfer_in_par…; and 1 more | ebcc7761f178 on d9f2b7bcfa07
Builds: issue 1975 note 2: lead answer L4, acceptance 1975/AC1-AC4

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:24:44 +01:00
jschoubben bf7d040489 mesh-recovery A-143: the dump wait and the snapshot-restored wait are bound keys
Each wait's bound is a key of the module's state bounds with its own time to
live, dump-wait.<act> and restore-wait.<act>, its expiry heard on the key's
watch, so no timer stays in the process.

Builds: design 47 G A-143, A-77, A-138, §681, §561

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:24:44 +01:00
jschoubben 80ade3cacb mesh-recovery: every bound a setting, the expiry on a bound key's expiry
point-keep-days, dump-wait-minutes and snapshot-restore-wait-minutes may
be fractions, and expire-every-hours (24 unless set) times the removal of
old points by a key of the state bounds whose own time to live the bus
ends, heard as its change: no timer in the process.

Builds: design 47 G §551, §557, A-77, A-130, A-138

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:18:22 +01:00
jschoubben 3ebcb15a4b systemd: serve kernel-log, the kernel's messages of one boot (issue 1975)
journalctl --dmesg --boot=N under the same escalation, window, priority, fixed-string match, 2000-line cap
and redaction as the journal verb (shapeLines is the one filter both use); a boot is 0 or a negative whole
number, refused otherwise before anything runs. No scope: the kernel belongs to no manager.

Builds: issue 1975, the operator's request of 2026-11-12 (the lead's handover; no design line names the verb)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:17:28 +01:00
jschoubben 1ed0dca7df mesh-recovery: an act cut off by the process's stop ends stopped
A move or a restart stops the process: from its stop signal every call
is answered an error naming the stop, the running act ends through its
own failure path (an aside put back whole, whatever its context), and
every accepted act not ended ends failed with stopped and its event, so
its caller asks again.

Builds: design 47 G §562, A-141

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:15:38 +01:00
jschoubben 810254193c mesh-recovery: the hand verb restore takes a keeper's snapshot by request
An unprotected snapshot is received through snapshot-get into
incoming/<request>, refused before the first chunk past the cap, checked
against its digest before any aside, and restored by the module; a
protected one is verified, the running data set aside and restored in
place by the keeper, waited on by snapshot-restored within
snapshot-restore-wait-minutes, cancelled at the bound. The bus module
sends its chunks with mesh-sdk's form.

Builds: design 47 G §681, §682, A-137, A-138, A-139, A-140

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:12:32 +01:00
jschoubben ba3fd46e82 Join 9: mesh-conditions marks owner-handover-seconds runtime, as the keeper reads it itself
The validator refused it as placed nowhere (ADR 0348 §5): the keeper reads it from its settings
beside ask-bound-seconds and view-waits-seconds, which are marked so.

Builds: design 47 G §557

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:09:42 +01:00
jschoubben dafa0691ad Join 9: claude-code adopts its licence binding's first view through its views reader (A-134)
The manager says binding.changed.<consumer>: the module's views reader hears it by the adoption
and asks the first view (FirstBinding) once its subscription is taken, instead of a subscription
of the exact subject of its own.

Builds: design 47 G A-134

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:03:14 +01:00
jschoubben 22a90d4267 Join 9: mesh-vault adopts the licence bindings' follower through a views reader, on its one writer (A-134)
The follower main added (issue 1409) used the removed views.FindingWriter: it is adopted by a
views.Reader holding the vault's one finding writer, which the assignments view shares, and hears
claude-licence-manager.binding.changed.<consumer> through the adoption.

Builds: design 47 G A-16, A-28, A-134

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:01:08 +01:00
jschoubben 739c0c23c6 Join 9: the licence manager says binding.changed.<consumer> (A-134)
The consumer is the event's further part, the one form of G's seat events, so mesh-vault and
claude-code adopt it through their views reader.

Builds: design 47 G A-134

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:57:58 +01:00
jschoubben a81f5ed0e3 mesh-recovery A-133: the manifest follows the validator
Its tool check stands on its own runtime beside the process's unit check, its
template states are its own by requires alone (not listed again in the
process's bus, which keeps its account), and digests takes the state
template's fixed cap (A-135).

Builds: design 47 G §657, §567, §554, A-133, A-135

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:57:34 +01:00
jschoubben c3e75e35e1 Merge origin/main (licence manager on the bus, issue 1409) into feat/47-G-join9
claude-code's and mesh-vault's manifests take both sides' additions (G's seat reads and
findings-asked, main's licence seat verbs and events); claude-code's manifest test lists both.
mesh-vault's licence-bindings follower still uses the removed views.FindingWriter: fixed after.

Builds: design 47 G §610

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:48:43 +01:00
jschoubben 3a6a127c38 mesh-recovery: its settings' files from templates under files/, the tools binary ignored
The catalogue keeps no file text inline for a new module: each setting's file is a template
under files/, packed by the module's archive artifact. A local build of the tools bundle
leaves a binary the module's ignore file now names.

Builds: design 47 G §633, A-112, A-130

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:45:44 +01:00
jschoubben 737cb95c47 Merge branch 'feat/47-G-fix9-builder2' into feat/47-G-join9 2026-11-12 01:44:28 +01:00
jschoubben d854f936f9 mesh-recovery: points-filling, recovery-waits and aside-unrestored through mesh-sdk's finding writer
The module measures its points directory against the fill mark of its cap at its start and
after each act, writes recovery-waits for an act that waits on the dump past its bound and
clears it once the act starts its work or ends, and writes aside-unrestored for each aside
only it holds the data of, cleared once the aside is restored or discarded. The bounds are
the settings points-filling-mark and dump-wait-minutes, read at the start.

Builds: design 47 G §561, §557, §564, §633, A-112, A-130

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:40:49 +01:00
jschoubben 9e41f6380a mesh-recovery: the process, on its own account, answering the seat's verbs on the live bus
The process reads its broker account, answers the seat mesh-recovery's verbs itself with the
caller the bus proved, takes and restores streams through mesh-sdk's bussnap, keeps the digests
in its state, names a kept data's bus objects from its catalog entry as mesh-sdk names them,
learns the fingerprint a module runs at from the node-engine seat's running and the bus
module's dumps from the backup holders' item events, each read whole at its start, and asks
the backup holder on its node to bring the points directory back. A restore whose
restore-started the bus did not acknowledge reads and sets aside nothing.

Builds: design 47 G §554, §516, §551, §556, §563, §567, §655, A-107, A-112, A-121, A-123, A-126, A-132

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:37:55 +01:00
jschoubben 4cd0b42d80 fix9b2-5: the readable check judges a read in the seat form against the modules claiming the seat
Builds: design 47 G A-128, A-116
2026-11-12 01:31:48 +01:00
jschoubben bd2997cf36 A-129: kept-points says the unrestored asides; recovery_kept_points answers them as the health check
The module's declared health check names recovery_kept_points: it answers what kept-points
answers with the health answer beside it, unhealthy while an aside only it holds the data of
is listed.

Builds: design 47 G A-129, §554, §657

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:30:00 +01:00
jschoubben 6d45f3ef58 A-125: the bus module imports mesh-sdk's bussnap and busarchive, its copies deleted
The take and restore of one stream is shared code in mesh-sdk; the bus module's keeper, its
nightly dump and its tools import it from there and keep no copy.

Builds: design 47 G §655, A-125

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:26:38 +01:00
jschoubben 968a0aac7a mesh-recovery: the tools bundle, recovery_ tools calling the seat's verbs with the proved asker
Builds: design 47 G §554, §656, §657, §682
recovery_kept_points reads kept-points; every hand tool (take, restore, retry, aside, discard_aside, restore_aside, cancel, expire_points) is one verb of the seat called with the asker the tool runner proved; the bundle holds no grant of its own.

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:19:32 +01:00
jschoubben 572a2b3a4b mesh-recovery: the seat's verbs, answered by the process with the proved caller
Builds: design 47 G §516, §554, §656, §682, §691, A-107, A-115
Each acting verb answers one of accepted, busy or error; arguments are read afresh per call and refused when unknown (local-name, from, aside-only, discard-aside) or without why; restore takes exactly one of snapshot and request (request is refused until built); the hand-act log names the proved account and the asker.

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:18:43 +01:00
jschoubben 2dd02da76e mesh-recovery: aside sets the live data aside from the placement's entry, names as a list
Builds: design 47 G §516, §554, §566, A-123, A-124
aside takes the bus names from the catalog entry of the fingerprint the node-engine's placement runs, takes the aside whole and checked, removes the live objects, says set-aside; restore-aside puts it back.

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:17:41 +01:00
jschoubben 0ebabf7174 mesh-recovery: restore, set aside, put an aside back, cancel, retry, kept-points, the repair at a start
Builds: design 47 G §516, §554, §560, §562, §563, §564, §566, §567, §682, §691, A-117
restore says restore-started, waits for the dump, finds the point (or has the backup holder bring it back), checks its digests against the bus, sets the live data aside whole and checked, removes it, restores the point and its consumers, and puts the aside back when a step after the removal fails; cancel ends a waiting act failed/cancelled with the hand act's words; at a start it clears temporary points, puts an aside back and ends a cut-off act failed/stopped.

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:16:29 +01:00
jschoubben 7d755c8f08 mesh-recovery: take a restore point, list the points, expire them, one act at a time
Builds: design 47 G §516, §551, §554, §560, §564, §567, §517
Accept writes the hand-act log first, answers accepted/busy/error at once and writes the act before it answers; take writes each stream under a temporary name, its digests on the bus before ended; the cap is refused before anything is written; expiry never removes the newest point.

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:13:58 +01:00
jschoubben b43c57f43b mesh-recovery: the module, its manifest and its claim of the seat mesh-recovery
Builds: design 47 G §554, §656, §657, §633, §564, §557, A-107, A-115
The seat claimed once for the mesh with every verb of its definition; a process answering them (serves beside bus), a tools bundle, the data item points, the state digests (§567), the findings of §561, the four settings.

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:13:13 +01:00
mesh-tools d9f2b7bcfa Merge pull request 'The licence manager keeps its data on the bus and switches licences by itself at 99% (issue 1409)' (#657) from feat/39-licence-switch into main 2026-11-12 00:13:04 +00:00
jschoubben c6de76740f Fix 9.4e: the four readers use the branch follower's Entry, Ready, entries rows, entry-not-found finding and entry_again tool; their copies are deleted
Builds: design 47 G A-103, A-15, §614

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:12:43 +01:00
jschoubben dbcba161d5 M-6 fix: the carry-over carries each live lease with its remaining lifetime (issue 1409)
mesh/delivery-group group feat/39-licence-switch merged: every member is merged
mesh/delivery merged: source.main moved to novox/mesh-catalog@d9f2b7bcfa07
mesh/check checking dbcba161d57c on d9f2b7bcfa07
The lead's answer B44: a lease the bundle before held in today's database
and still live at the run is written to `leases` under the bus's key form
with what is left of its lifetime, so no refresh races it whatever the
delivery's order; an expired one is counted read and not written.

Builds: design 39 bus-and-switch §4, §12, B44

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:07:09 +01:00
jschoubben c8e321c8e1 M-11 fix: a reading takes its licence's lease, and a grant the vault does not answer is a failed reading (issue 1409)
Review round 1, finding 5: the reading reads the grant under the licence's
lease (another run holding it reads nothing that round), and a grant the
vault refuses writes readError, keeps the percentages and goes on to the
next licence, so the conditions are still said after the round.

Review round 1, finding 6: these answers were built by earlier commits that
did not name them: B26, B27, B28 and B29 by M-6 (9cc62818), B30 by M-9
(39912119), B35 by M-11 (6cf98386); B23 and B24 by V-1 (e85cf002) and M-7
(5ca3876b), named again in b8e8be61; B31 and B32 in 36d9d739.

Builds: design 39 bus-and-switch §4, §16, §24, §31, B23, B24, B26, B27, B28, B29, B30, B34, B35

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:04:07 +01:00
jschoubben b8e8be619d M-15 fix: resync hands the vault each access token built from its grant (issue 1409)
Review round 1, finding 4: the vault hands a licence's access token only to
a machine a binding names, so licences_resync builds it from the grant (the
key itself for an API key) instead of reading it back.

Builds: design 39 bus-and-switch §15, §17, §42, B23, B24

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:02:54 +01:00
jschoubben cc8a354f75 M-3 fix: an adopted licence says where it came from, and a newer login keeps its reading (issue 1409)
Review round 1, finding 3: adoptedFrom is the node a login came from, or
where an API key came from; a newer login of the same account keeps the
latest reading and generation the key holds.

Builds: design 39 bus-and-switch §2, §34, §46

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:02:14 +01:00
jschoubben 6a57bb7218 M-6 fix: each carried binding is said as binding.changed (issue 1409)
Review round 1, finding 2: a reader's first view taken while the carry-over
runs is brought up to date by the event said after each key is written.

Builds: design 39 bus-and-switch §12, §21

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:01:40 +01:00
jschoubben 5d1456257c Fix 9.3: the nats keeper's process serves its four mesh-broker snapshot verbs, beside its bus
Builds: design 47 G A-109, A-110, A-115 (serves is a field of the process itself)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:00:59 +01:00
jschoubben 36d9d73984 V-3 fix: no access token before the first view is answered, and a first view keeps a newer event's key (issue 1409)
Review round 1, finding 1, and the lead's answer B43. Until the vault's first
view of the licence bindings is answered since its start, licence-put's
access token is refused, saying why. A first view removes a consumer's key it
does not name only when that key was written before the view was asked. The
newer generation's binding staying over a first view (B32) was built in V-2's
keep and is named here.

Builds: design 39 bus-and-switch §17, B31, B32, B43

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:00:42 +01:00
jschoubben 0acad94ba5 Fix 9.2: the gate's catalogue test reads a consumes entry naming the seat's own event as the seat, not the module
Builds: design 47 G A-30, §507 (consumes names a seat's events by the seat)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 01:00:25 +01:00
jschoubben 056e4ea915 Fix 9.1: claude-code, mesh-config, mesh-provisioner, mesh-self-check require the meshdelivery contract their catalog feed test imports
Builds: design 47 G A-30, A-103 (requiredmodules check, issue 1921)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 00:59:01 +01:00
jschoubben 9cf5c1ddbd V-4: the vault's first-view tools cover the licence bindings, and its README says the licence secrets (issue 1409)
vault_first_views lists the licence bindings' first view beside the
assignments'; vault_first_view_again asks the one its new argument `of`
names, and both when it is absent.

Builds: design 39 bus-and-switch §52, §15 to §17, B21, B42

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:42:53 +01:00
jschoubben 3b92e02862 C-4: the agent module's README says it follows its binding by event and takes its token from the vault (issue 1409)
Builds: design 39 bus-and-switch §52, §19, §22, §23, B33

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:39:49 +01:00
jschoubben a8131809b2 M-17: the manager's README says what it keeps on the bus and how it switches (issue 1409)
Builds: design 39 bus-and-switch §52, §1 to §13, §21, §23 to §46

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:39:21 +01:00
jschoubben b8ce072c9f M-16: the manager whole on a real bus and today's database on a real postgres (issue 1409)
The built binary played as its runtime plays it, its kept data in a real
nats-server's buckets and streams, today's rows in a real postgres: a start
stopped at a table says carry-over-failed and acts on nothing, the next
carries every row and a further one writes nothing; an unreachable database
calls no vendor; a reading at the threshold moves four consumers in one act
and no-room is raised and cleared on the operator channel's subject, read
through its client; a new login is adopted, listed and a target.

Builds: design 39 bus-and-switch §9, §11, §12, §13, §23, §26, §28, §29, §46, B41

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:37:54 +01:00
jschoubben 7b16e195ee Join 8: mesh-config and mesh-provisioner keep no builder package (recover-readers), version 0.1 (judge5)
Builds: design 47 G join of rounds 4-8
2026-11-12 00:28:21 +01:00
jschoubben baaf2e56dd M-15: the manager's tools read all it keeps and start by hand all it does (issue 1409)
The licences verb answers each licence's organisation, latest usage with its
reset times, its room or why it is not known, whether it is where a full
licence's consumers move next, when and from where it was adopted and its open
conditions. The usage verb takes since, a limit of at most 2000 and raw. The
module's tools: licences_switches from the audit, licences_read_usage and
licences_switch_now with dry, licences_settings with the level mesh-config
answers (the manifest invokes its config verb), licences_kept over every
bus-state entry with its fill, licences_resync, licences_conditions and
licences_carried with its run, refused once carried. An acting tool waits for
the carry-over.

Builds: design 39 bus-and-switch §34, §35, §37, §38, §39, §40, §41, §42, §43, §44, §47, §20, §11, B9, B18, B40

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:19:22 +01:00
jschoubben 4b749286bd Merge branch 'feat/47-G-recover-readers' into feat/47-G-join8
# Conflicts:
#	modules/mesh-config/module.json
#	modules/mesh-provisioner/module.json
#	modules/mesh-self-check/module.json
2026-11-12 00:14:26 +01:00
jschoubben ebaa4c9946 Merge branch 'feat/47-G-recover-small' into feat/47-G-join8 2026-11-12 00:14:17 +01:00
jschoubben 231975bce6 Merge branch 'feat/47-G-buildfix6' into feat/47-G-join8 2026-11-12 00:14:17 +01:00
jschoubben 35fb415f7b Merge branch 'feat/47-G-issues6' into feat/47-G-join8 2026-11-12 00:14:17 +01:00
jschoubben 14d1a919cc Merge branch 'feat/47-G-judge5' into feat/47-G-join8 2026-11-12 00:14:17 +01:00
jschoubben 20e92dacd5 Merge branch 'feat/47-G-cond6' into feat/47-G-join8 2026-11-12 00:14:16 +01:00
jschoubben 9e545658a5 M-14: the manager's conditions, kept in its state and said on the operator channel (issue 1409)
Each condition the manager holds is written in its state conditions with its
body and since, then said on the operator channel's condition accept under
claude-licence-manager, scope module; it is said again only when its words
change, and said cleared, its key deleted, once it no longer holds. At its
start, after the carry-over's run, it judges every kind anew: raised again
each it holds, cleared each kept key it no longer holds. The kinds:
carry-over-failed, settings.<setting>.invalid for a pair above
switch_at_pct and for a setting outside its bound, and per licence no-room
when the rule finds no target, near-limit at warn_at_pct with consumers, and
usage-unread once its latest success, or its adoption when none, is older
than usage_stale_minutes. Headlines name counts and short names, cut at 60.

Builds: design 39 bus-and-switch §9, §11, §29, §30, §31, §33, B17, B34, B36, B37, B38, B39

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:13:05 +01:00
jschoubben 4e10ab7b2e M-13: a licence at its threshold moves every consumer, in one act (issue 1409)
After each reading of a licence with consumers whose session or weekly
window is at or above switch_at_pct, the manager moves every consumer bound
to it, when switch_auto is on and the settings are valid, to the other
licence with known room whose windows are both below room_below_pct and the
most room, the first by name on a tie. Each consumer moves as the switch verb
does, by usage, and says binding.changed; the act says one licence.switched
and one audit entry switched with the window, its percentage and reset and
the target's room, and is told to the operator; the switch verb is not told.
By hand the rule runs with switch_auto off, and dry answers the moves alone.

Builds: design 39 bus-and-switch §26, §27, §28, §32, §33, B22

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:00:19 +01:00
jschoubben 954e3afe56 M-12: the switch's settings, read again at every reading (issue 1409)
The settings gain switch_auto, switch_at_pct, room_below_pct, warn_at_pct,
usage_minutes and usage_stale_minutes with the manifest's defaults and
bounds, a value outside its bound keeping the default. The settings file is
read again at every reading, each changed value written in the audit under
settings, and the usage timer follows usage_minutes. A room or warning above
switch_at_pct is found as an invalid pair naming both values.

Builds: design 39 bus-and-switch §33, §24

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:57:38 +01:00
jschoubben 605c583ab4 bus5 B6 (A-109): snapshot-get's chunks go on a core publish to any caller; no grant check
grantedBy is dropped: any caller of snapshot-get gets the chunks, a protected snapshot never.
The chunks are a transfer on the core bus, published with a core publish and kept on no stream.

Builds: design 47 G A-109 (G-20-lead-answers), §681

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:57:09 +01:00
jschoubben 44cb7ad449 bus5 B5 (A-107): the keeper's process answers mesh-broker's snapshot verbs itself through AnswerSeat
snapshot-get, snapshot-restore, snapshot-restore-cancel and snapshot-remove are answered by
the keeper's process with the proved caller; it publishes snapshot-get's chunks and hears
their acks on its own subscription, and acts at once with no hand-over through requests
(the record of a running restore is written before it runs). The tools bundle keeps its
tools, nats_snapshots among them. The protected refusal and the whole test's case (send,
restore in place, removal) stay.

Builds: design 47 G A-107 (G-20-lead-answers), §681

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:56:06 +01:00
jschoubben 6cf98386e9 M-11: each reading keeps its three reset times, and a licence's room (issue 1409)
The usage reading flattens the session, weekly and sonnet windows each with
its reset time, keeps it beside the licence with when it was read, and says
it as usage.read; a failed answer keeps the last percentages and writes the
vendor's status and reason. A licence's room is 100 less the higher of its
session and weekly windows, known only for a subscription read within
usage_stale_minutes whose refresh is healthy; an API key's is never known.

Builds: design 39 bus-and-switch §24, §25, §48, §2

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:55:22 +01:00
jschoubben bd65b023bd M-10: the manager hears what each node holds as holdings-changed (issue 1409)
The manager consumes the agent module's holdings-changed, keeping each report
as its node's latest and considering them at once, in place of reading the
agent module's state; once it hears them, at every start after its
carry-over, it says holdings.asked with its time and who asks, so every node
says what it holds again.

Builds: design 39 bus-and-switch §23, §46, B12, B22

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:53:10 +01:00
jschoubben 31ce47d1cc C-3: a node takes its licence token from the vault (issue 1409)
The agent module asks the seat's current which licence it holds and its key,
then the vault's get for that licence's access secret sealed to its own key:
at its start, on a binding.changed with a newer generation than it applied,
and on the vault's secret-changed for the licence it holds. The applied
binding keeps the licence's key, so the vault's event is matched by the
carried key, never one formed here.

Builds: design 39 bus-and-switch §19, §18, B7, B9, B11

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:51:00 +01:00
jschoubben 6eccfc286a C-2: a node follows its licence binding by binding.changed and a first view (issue 1409)
The agent module consumes the manager's binding.changed, acting only on the
one naming its node's consumer, and each time it hears those events anew asks
the seat's bindings for its own consumer as its first view; its read of the
manager's bindings state goes.

Builds: design 39 bus-and-switch §22, B9, B10, B22

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:44:12 +01:00
jschoubben 110c631b5f V-3: a licence's token goes only to a machine a binding names (issue 1409)
The vault's get judges a licence's secret by a rule of its own, before the
asker rule: a grant goes to any proven caller, and an access token to a
machine on which a consumer's binding in licence-bindings names that
licence's key; a refusal names the rule and is recorded.

Builds: design 39 bus-and-switch §16, §17, A1, B3, B8, B25

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:42:11 +01:00
jschoubben 256a412590 V-2: the vault keeps the licence bindings from binding.changed and a first view (issue 1409)
The vault consumes the licence manager's binding.changed and, at its start,
asks the seat's bindings until it answers; it keeps each consumer's licence,
the licence's key and the generation in its own state licence-bindings,
never an older generation, deleting a released consumer's key. The state is
in the vault's old state section, and holds no secret.

Builds: design 39 bus-and-switch §17, B2, B9, B10, B22, B25

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:39:15 +01:00
jschoubben e0428bd874 W16.2: mesh-config's whole test says the catalog-revision event on the branch's part, as the bus says it
Builds: design 47 G §532, §614; lead answer A-101

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:34:51 +01:00
jschoubben da5bbe0a17 W16.2: mesh-config reads which modules exist, their manifests and a build's manifest from the catalog half through meshcatalogclient's branch follower (milestone 47/G)
Builds: design 47 G §196, §594, §614; lead answers A-5, A-15

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:34:28 +01:00
jschoubben b63e8ae58a M-8: the bindings verb answers each licence's key (issue 1409)
Each binding the verb bindings answers carries its licence's key beside the
§3 fields, as binding.changed does, so the vault's first view matches a
token's secret name to the binding without the key form of its own.

Builds: design 39 bus-and-switch §17, B10, B25 (hq 128236e7 and after, PR #561)

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:32:31 +01:00
jschoubben 3991211993 M-9: every change of a binding is said as binding.changed (issue 1409)
Bind, switch, release, a login's move and a rotation's advance each say one
binding.changed per consumer moved, with the licence's key, after the key of
bindings is written or deleted; a switch through the verbs says one
licence.switched with the from, to, consumers, by and why the audit's
switched entry also holds. licence.bound, released and rotated are said no
more. An adoption advances the consumers already bound before it moves the
login's nodes, so each is said once.

Builds: design 39 bus-and-switch §21, §27 (the verb's switch), §36, B7, B16

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:30:39 +01:00
jschoubben 9cc62818f4 M-6: today's rows carried over once, before the manager does anything else (issue 1409)
At its first start the manager reads the six tables of its database, read
only, and writes them into its kept data and its grants into the vault at
generation 1, then carried.postgres; until then only its read verbs answer.
A second start never opens the database, and a stopped run is done again
whole without doubling a log entry. The store's write code and its postgres
test go: nothing writes the database again.

Builds: design 39 bus-and-switch §1, §8, §11, §12, §13, A3, B14

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:28:14 +01:00
jschoubben 7278e509f4 W16.5: claude-code reads which modules exist and their manifests from the catalog half through meshcatalogclient's branch follower (milestone 47/G)
Builds: design 47 G §196, §594, §614; lead answers A-5, A-15

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:25:52 +01:00
jschoubben 0098960b5a W16.3: mesh-provisioner reads modules and manifests from the catalog half through meshcatalogclient's branch follower (milestone 47/G)
Builds: design 47 G §196, §594, §614; lead answers A-5, A-15

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:24:35 +01:00
jschoubben c41a84ee95 A-C5: mesh-build-node builds against the latest published SDK, the toolchain pins none
Recovered from cfee5f2d (feat/47-G-buildmods).
Builds: design 47 G §571 (the build writes a mesh package's lock entry from the latest published version), A-56

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:23:44 +01:00
jschoubben ff07c3a668 K-A2: mesh-config asks every proposal; the router holds the oldest back
Recovered from 71370afc (feat/47-G-asks).
Builds: design 47 G §40; design 46 §6 Limits, batching, history

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:23:44 +01:00
jschoubben 7df879b367 K-A1: claude-code asks every question and proposal; the router holds the oldest back, the slot keys go
Recovered from 90d70b83 (feat/47-G-asks).
Builds: design 47 G §40; design 46 §6 Limits, batching, history (no ask hidden or refused for its asker's count)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:23:44 +01:00
jschoubben 095e23ecda W16.4: mesh-self-check reads which modules exist and their manifests from the catalog half through meshcatalogclient's branch follower (milestone 47/G)
Builds: design 47 G §196, §594, §614; lead answers A-5, A-15

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:21:31 +01:00
jschoubben 2b604e0dad M-5: the usage and audit logs kept in the bus's own storage
Every reading is appended under its licence's key with the vendor's answer whole (ADR
0054), and every act under its kind, never rewritten; an act whose audit entry the log
does not take fails and says so. The memory store goes: the tests run the bus store over
a bus kept in memory.

Builds: design 39 bus-and-switch §6, §7, B20

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:19:03 +01:00
jschoubben 9770dab09e M-4: leases and offered grants kept in the bus's own storage
A lease is a key created with a two-minute lifetime, which fails while another holds it:
the bus's own compare-and-set keeps one rotation source (ADR 0183 §6). An offered grant's
outcome is kept under its fingerprint in the key form for 90 days, every outcome kept.

Builds: design 39 bus-and-switch §4, §5, B13

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:17:16 +01:00
jschoubben 15094d6e72 M-3: licences and bindings kept in the bus's own storage
The state licences holds one key per licence by its key form, never a secret, with its
latest reading beside it; the state bindings, the same local name the consumers read,
holds each consumer's licence, kind, generation, since, why and by, written by the store
itself. Each consumer counts its own generations, past what its node reported.

Builds: design 39 bus-and-switch §2, §3, B11, B22

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:16:25 +01:00
jschoubben 5ca3876bea M-7: every grant and access token lives in the vault
The manager hands what the vendor answers to licence-put, the grant first and then the
access, at the licence's next generation, and reads a grant with get for one act; it keeps
no token at rest (ADR 0550 §3). The sealing at rest goes; opening stays for the carry-over.

Builds: design 39 bus-and-switch §15, §16, §20, §45, B15

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:13:31 +01:00
jschoubben f29a9ad563 M-8: current answers the licence and its health, never the token
The vault hands every token (ADR 0550 §3); current names the licence, its key, kind,
generation, health and access expiry, and keeps the account's identity for the agent's
account file. bindings takes an optional consumer, answered unpaged.

Builds: design 39 bus-and-switch §18, B7, B10, B19

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:11:09 +01:00
jschoubben 4179be7a56 C-1: a node's holdings are said as holdings-changed, and again when asked
The licence manager follows the nodes' holdings by an event and asks for them with
holdings.asked, instead of reading the agent module's state (ADR 0596 §2). The state
holdings stays the module's own.

Builds: design 39 bus-and-switch §23

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:09:22 +01:00
jschoubben e85cf002a4 V-1: the vault serves licence-put, keeping the caller's newest generation
A licence's grant and access token move into the vault (ADR 0550 §3), handed in two steps
with a key made for the call, as move-in does; the caller's generation is the record's,
an older one is refused, and secret-changed names the secret and its generation.

Builds: design 39 bus-and-switch §15, B3, B4, B5, B6

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:07:44 +01:00
jschoubben 8f3c810fc3 §600: mesh-build-go's ask workers filter both forms of the accept subject
Builds: design 47 G §600, §483

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:05:30 +01:00
jschoubben c854373840 ISSUES: mesh-issues reads git.pull, git.contains and the runner's list and show, not mesh-delivery.checks and the forge's tools
Pull request facts come from seat git's pull, ancestry from contains, the review time from the runner's review step,
and get shows one pipeline row per module (no run where the runner's list answers none).

Builds: design 47 G §654, A-75

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:05:28 +01:00
jschoubben 49bace0073 A-72: no @novox/mesh-* package counts as beyond the SDK in mesh-build-node
Builds: design 47 G G-20 A-72

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:02:50 +01:00
jschoubben 1c03299721 M-2: the manager's manifest asks for its kept data by template
Eight bus-state entries in the typed long form; the old state and the reads of
claude-code.holdings go, and so does the run-once prepare, since nothing writes the
tables again. The database and grant-key stay as the carry-over's read-only source.
The manifest declares its new events, its consumption of holdings-changed, the
operator channel it uses, the verbs it invokes, six settings and its eight tools.

Builds: design 39 bus-and-switch §1, §9, §21, §23, §27, §33, §37-§44, A3, B9, B16

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:01:43 +01:00
jschoubben e4c5108306 JUDGE: the module tests that read a manifest follow the manifests' new forms (version 0.1, findings in requires, the foundation's old sections)
sudo and localization expect version 0.1; distribution and gitea read the old objects and state sections; mesh-build-go, mesh-knowledge, mesh-provisioner and mesh-self-check expect the findings asked in requires and the keeper's ask consumed. Each assertion failed against the changed manifest before it was changed.

Builds: design 47 G §368, §529, §584, A-30

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:00:47 +01:00
jschoubben e995e40519 M-1: a licence's key form, and an adoption whose key is taken is refused
A key of the bus takes fewer characters than a licence's name; the key is the store's
alone, and two names that write the same key would overwrite each other.

Builds: design 39 bus-and-switch §10, B6

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 22:58:52 +01:00
jschoubben a9dfe77b1d bus5 B4 (A-64): snapshots-filling has scope module and id nats.snapshots
Builds: design 47 G §681, A-64
2026-11-11 22:56:49 +01:00
jschoubben caa350f350 bus5 B4 (§529): nats go.mod requires the meshconditions contract it imports (requiredmodules check)
Builds: design 47 G §529, A-30
2026-11-11 22:55:28 +01:00
jschoubben 673b9d075a HEAL2 (the healers' findings in requires): the manifest test holds the state and the stream asked by their templates
The healers' manifest already asks findings (template state) and finding-events (template events, the three finding events,
7 days, 16 MiB) and declares no old stream or findings state, and their writer reaches the state by its local name (built in
76be864b and the join); this adds the test that holds it.

Builds: design 47 G §529, §577, A-30

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:54:43 +01:00
jschoubben 47aaee122c bus5 B3 (§681, A-44, A-45, A-59): the bus module serves snapshot-get, snapshot-restore, snapshot-restore-cancel and snapshot-remove through its tools bundle, sends the chunks, hands the work to the keeper through requests; nats_snapshots; the whole test covers send, restore in place and removal
Builds: design 47 G §681, ADR 0675, lead answers A-44, A-45, A-59
2026-11-11 22:54:35 +01:00
jschoubben 22b50c88a9 HEAL1 (attempt-changed): the healers say each attempt they write, and every attempt kept at their start
The seat mesh-healers' attempt-changed carries the attempt whole with until (a week from its write), on the subject keyed by
the condition and the attempt, each escaped as a key's name is. The event is the seat definition's; the manifest's emits stays
empty as for healer-acted.

Builds: design 47 G §541, §665

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:54:18 +01:00
jschoubben 265d3b83b2 COND8 (haves and attempts as events): the keeper hears what a node runs and the healers' attempts, and reads neither state
The node-engine's composed, running, wait-held and wait-ended make the have the hold compares with the wants; the healers'
attempt-changed makes an attempt, dropped at its until on the keeper's own clock (a bound with its own time to live) or when
removed. The manifest's reads are gone and its consumes gain the five events.

Builds: design 47 G §541, §665, §556, §499, §434

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:53:09 +01:00
jschoubben 72ccb75c1b COND10 (stated kinds, both subject forms): the keeper clears only conditions of the kinds a statement covers
A findings-stated carrying kinds sets the keeper's view of that observer, node and set of kinds, and clears only the
conditions of those kinds it does not name; no kinds covers every kind. The finding events are taken from either form of the
part's finding stream, the observer and node read from the subject.

Builds: design 47 G §580, §529 (A-30), §383, §384

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:50:17 +01:00
jschoubben 790b5aac7d bus5 B4 (§681, A-29, A-40, §529): the bus module writes snapshots-filling through mesh-sdk's held writer over its state findings; nats-findings declared in the foundation form
Builds: design 47 G §681, §529, A-17, A-29, A-30, A-40
2026-11-11 22:49:07 +01:00
jschoubben b15c511528 COND7 (the condition's owner): a condition carries the finding's owner and waiting-on; a failed head is the operator's two hours after its since
The keeper copies owner and waiting-on from the finding at raising (and into a condition kept from before G at its next
change), hands a pipeline-failed to operator-channel after the setting owner-handover-seconds (default two hours, timed
from the finding's since as a bound with its own time to live), says each change as the change owner, and the tool
conditions_owner sets an owner now, written in the hand-act log.

Builds: design 47 G §540, §685, §678, §557, A-58 (seed-waits: the indexer sets the finding's waiting-on; no kind rule here)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:48:54 +01:00
jschoubben d4e29a07b2 LOCKS (A-56): the locks check counts the mesh's own @novox/mesh-* packages as it counts the SDK
A module naming only the mesh's packages beyond typescript and the types needs no lock (gitea names the contracts' git and mesh-catalog packages); a row in the first test fails without the change, as does the catalogue's own run.

Builds: design 47 G A-56, ADR 0469 §5

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:46:08 +01:00
jschoubben d627400cf1 bus5 B2 (§681): the keeper restores a snapshot in place (verify, names live, 60-minute bound, cancel, partial stream removed, snapshot-restored); requests keeps records 91 days (A-53)
Builds: design 47 G §681, ADR 0675, lead answers A-45, A-53
2026-11-11 22:45:56 +01:00
jschoubben 41567adee0 JUDGE (§584, A-54): distribution, a foundation part, keeps its bus state in its old sections
incoming goes to the old objects section; transfers, idle (key-ttl), holds, windows, mirrors and findings to the old state section; the stream events becomes the old stream distribution-events (A-54, EVENTS being the mesh's own stream).

Builds: design 47 G §584, A-54, ADR 0619 §2

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:45:40 +01:00
jschoubben f5ce980808 GATE (A-42, §592): the gate's approval-carried-open has scope delivery
FindingScope is delivery, the scope the mesh already uses for the delivery's findings; the test names the literal (it failed with gate).

Builds: design 47 G §592, A-42

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:45:09 +01:00
jschoubben 0bd978f4ee JUDGE (ADR 0348 §5, §557): the keeper's two settings are marked runtime, and mesh-conditions requires the catalog client it imports
ask-bound-seconds and view-waits-seconds are read by the keeper's own code at its start (settings.go ReadSettings, stdio.Config), so the manifest marks them runtime: true; go.mod requires meshcatalogclient at the zero version, which cmd/mesh-conditions/branches.go imports (the requiredmodules check).

Builds: design 47 G §557

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:44:29 +01:00
jschoubben af978cc559 JUDGE (§584): gitea, a foundation part, keeps its bus state in its old sections
transfers, idle (key-ttl), failed-steps (history 1) go from requires entries to the old state section and incoming to the old objects section, as §584 has the forge module keep every state in its old sections.

Builds: design 47 G §584, ADR 0619 §1

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:44:29 +01:00
jschoubben 6e343831c9 JUDGE (A-30, §529, §387): a part that is no foundation part asks its findings state and its finding-events stream in requires, and consumes findings-asked
claude-code, mesh-build-go, mesh-provisioner and mesh-self-check drop their old state findings and stream <module>-findings for the requires entries findings (state) and finding-events (events: the three finding events, 7 days, 16 MiB); mesh-knowledge gains finding-events and the consume. The manifest forms follow mesh-watcher's. The judge refused each for the old form (§529, §577) or for the missing stream or consume (§387).

Builds: design 47 G §387, §529, §577, A-30

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:44:28 +01:00
jschoubben 5b4b201558 bus5 B1 (§681): the keeper records its snapshot (path, size, digest, protected) before it touches live data, holds it to snapshots-cap, removes it after snapshot-keep-days, tells snapshots-filling
Builds: design 47 G §681, ADR 0675
2026-11-11 22:41:36 +01:00
jschoubben f509c61b50 VERSION (A-46, §368, §411): every manifest of the catalog says its version MAJOR.MINOR
mesh-delivery-builder.builder_versions answered no numbered version for any name asked (mesh-sdk go and ts, mesh-host, node-tools, mesh-delivery-builder, mesh-conditions), so every module takes 0.1, the floor §368 gives a module with none published: all 144 manifests (141 from "1", distribution, mesh-provisioner and mesh-provisioner-client from "2", which carries nothing over, §583).

Builds: design 47 G §368, §411, §583, A-46

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:38:45 +01:00
jschoubben 469763547c Merge branch 'feat/47-G-findings4b' into feat/47-G-dev4-join 2026-11-11 22:25:53 +01:00
jschoubben 9aae6244d5 Merge branch 'feat/47-G-findings4' into feat/47-G-dev4-join 2026-11-11 22:25:53 +01:00
jschoubben 6a612c58cb G findings: mesh-config, gitea and distribution declare their finding events in the foundation form, the stream <module>-findings in streams
Builds: design 47 G A-30, §529, §577
2026-11-11 22:24:58 +01:00
jschoubben d03ffa8038 G findings: mesh-config's and mesh-vault's owner views are followed by the reader (Follow), not adopted: they hear the owner's own views event
Adopt subscribes a seat's event with further parts (<seat>.<event>.>), which never matches the plain views event of an owner; the reader's Follow hears it and asks again on a gap. Also the distribution whole test counts its own two events by subject, as the writer's are said beside them.

Builds: design 47 G A-28, A-26, A-30
2026-11-11 22:22:24 +01:00
jschoubben f06dd01bfe GATE A-39, A-29, A-37, A-30, A-40, §592: the gate's findings go through one held writer over its own findings state, approval-carried-open is written for each approval carried and cleared by the seat git's pull-merged and pull-closed
Builds: design 47 G A-39, A-29, A-37, A-30, A-17, A-40, §542, §592

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:19:53 +01:00
jschoubben c81915b039 G findings: distribution-tools and package-registry make the held writer at once and start it after Serve, as it seeds at Start
Builds: design 47 G A-40, A-32
2026-11-11 22:19:22 +01:00
jschoubben 839147b146 G findings: artifact-store-tools and mesh-config read what their held writer holds, so a restart neither drops nor rewrites their findings
Their in-memory open sets go.

Builds: design 47 G A-32, A-29, A-38
2026-11-11 22:17:21 +01:00
jschoubben 14699713b9 GATE A-39: every change of a record owes its pull requests a note line and the delivery view, and a comment when it ends failed or held
Builds: design 47 G A-39, A-35, §158, §161 to §167

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:16:16 +01:00
jschoubben 652493e2ac G: the package seats' whole test plays the runtime's put and get sessions
Builds: design 47 G A-21, §518, §490

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 22:15:42 +01:00
jschoubben fc83f204f4 G findings: the held writers of the stdio bundles are made once the runtime has its channel
The held writer reads the keys of its state when it is made, so it is made after Serve answers state calls; the keeper says a finding is not said before it exists.

Builds: design 47 G A-32, A-29, A-37
2026-11-11 22:15:42 +01:00
jschoubben 3eee45aa49 GATE: the gate's whole-module test opens a record and a source key from a take-in.taken, and its test bus declares the template states
The test bus made buckets from the old state section alone, so a template state (deliveries, groups, owed, conditions) was 'not declared'; it now
makes a bucket for each requires entry of the template state under its local name (A-20).

Builds: design 47 G §613, A-20, A-33, A-34 (G-17, G-20)
2026-11-11 22:13:07 +01:00
jschoubben 9f4d4eecba G: the holder's test of a part sent again says what the receiver does since design 51 (chunk 0 again starts the part afresh, a chunk out of sequence is refused)
Builds: design 47 G A-21
2026-11-11 22:12:21 +01:00
jschoubben f0c7d468c3 GATE A-33, A-34: a take-in.taken opens one delivery record and moves its branch's source, saying source-moved
Builds: design 47 G §151, §291, §592, A-33, A-34 (G-3, G-5, G-16, G-20)
2026-11-11 22:11:59 +01:00
jschoubben 18fd2a32bf G findings: distribution and gitea's package registry write through the held writer, so a restart neither drops nor rewrites their findings
Their in-memory open sets go.

Builds: design 47 G A-32, A-29, A-37
2026-11-11 22:11:12 +01:00
jschoubben 7cc4a33cc5 GATE A-24, §172, §431, §592, §593, §613: the gate keeps no merge states; deliveries, show, groups and stalled answer the delivery records
Removes holder, table, approval, ready, onestatus, pullkey, checks, group_merge, the effects, the forge reads, the teller's
and the view's reads, kvOperator, kvPulls, delivery_merge_again and their tests. deliveries, show, groups and stalled answer
the records as kept (stalled: a record open past a day, and an act owed unanswered past 15 minutes, read from the record's
acts); the switch reads the old section deliveries and says approval-carried for each unperformed merges key. The manifest
drops pulls, asks, reads, the forge, teller and view consumes and invokes, and the events group and merged.

Builds: design 47 G §172, §431, §592, §593, §613, A-24, A-35 (G-3, G-9, G-16, G-17, G-20)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 22:10:42 +01:00
jschoubben 8255640d00 G findings: claude-code, mesh-provisioner, distribution, mesh-config and gitea declare the writer's three events
The writer listens to mesh-conditions.findings-asked, so each also consumes it, as the modules that moved before.

Builds: design 47 G A-30, §383, §686

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 22:10:19 +01:00
jschoubben 3215e5eeb9 G findings: distribution and gitea's package registry delete their old finding keys once at start
Builds: design 47 G A-31, A-14, A-22

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 22:08:33 +01:00
jschoubben 1cc67aa9d7 G findings: mesh-config's and mesh-vault's own followers go through a views.Reader holding the writer, adopted
Builds: design 47 G A-28, A-26, A-22, §687

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 22:07:02 +01:00
jschoubben 06ebd7e8cf G findings: the writers name the ask's subject from the contract; the readers take the writer whole
WriterConfig.Asked is the generated mesh-conditions contract's SubjectFindingsAsked (A-17), a reader's Findings is the
module's writer itself (A-22, A-26), and mesh-build-go's single manifest ask writes and clears its first-view-unanswered
finding through the writer with the contract's Finding (A-28). mesh-config and mesh-vault's own followers (A-28) are not
yet moved onto a views.Reader: they do not compile at mesh-sdk 24f876a.

Builds: design 47 G §687, §689, A-17, A-22, A-26, A-28

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 22:03:09 +01:00
jschoubben cd24677756 mesh-self-check and mesh-healers give the writer the findings-asked subject of the seat's contract, and the reader the writer itself
Builds: design 47 G A-17, A-16

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:59:18 +01:00
jschoubben 05fcebf9c5 COND A-22: the keeper holds findings as the contract's generic Finding and takes its own through the writer's wire form
Builds: design 47 G §434, §437, §439, A-3, A-22 (G-20)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:58:08 +01:00
jschoubben 95f29d6c54 G findings: the modules with findings of their own write them through mesh-sdk's writer
artifact-store-tools (image-missing, sweep-refused), distribution (program and tools bundle, one writer each over its own
kinds), gitea's package-registry (and the token-refused key the TypeScript bundle watches) and mesh-config keep their
findings open and write and clear them through the generic writer; mesh-vault states its assignments view's. A part of an
id told apart by a ref is in the id (<id>.<ref>), each escaped; the writer starts once Serve has the runtime's channel; the
removed findings helpers and resolved words go.

Builds: design 47 G §686, §687, §689, A-3, A-14, A-16, A-22

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:58:08 +01:00
jschoubben a587125047 GATE A-23, §431: the gate serves recovery, marking the delivery record's field recovery, and no longer serves checks
Builds: design 47 G §81, §132, §431, A-23 (G-20), A-18 as corrected

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:55:57 +01:00
jschoubben ab5423f87f GATE: the gate's manifest consumes the seat git's events with the hyphens the seat gives them
Builds: design 47 G §396, §310 (G-9: the seat git's events take hyphens)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:52:11 +01:00
jschoubben cbd4da3a11 GATE §613: deliveries, groups, owed and conditions are template state entries under the same names, taking the buckets they had
merges and source stay in the old state section; pulls and asks wait for the removal of the merge states.

Builds: design 47 G §613, A-8, A-20 (G-17, G-20)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:49:54 +01:00
jschoubben b78e90c0c7 mesh-healers writes its findings through mesh-sdk's finding writer and the generated finding
Builds: design 47 G §686, §689, A-3, A-13, A-14, A-16

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:49:17 +01:00
jschoubben 11d1be3ef8 GATE A-18: the gate's record type drops the remove operation, and table, recheck and close go with their handlers and tools
Builds: design 47 G §587, §305, A-18 (G-20)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:48:19 +01:00
jschoubben 9677f200f3 mesh-self-check writes its findings through mesh-sdk's finding writer and the generated finding
Builds: design 47 G §686, §687, §689, A-3, A-13, A-14, A-16

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:47:00 +01:00
jschoubben dd4449de61 G findings: the view-only modules write their reader's findings through mesh-sdk's stdio writer
nfs-server, minio object-store, claude-code, mesh-watcher, mesh-provisioner and mesh-build-go hand their views reader
views.WriterFindings over views.NewStdioWriter and start it; the removed meshconditionsclient.FindingWriter goes.

Builds: design 47 G §686, §687, §689, A-3, A-16, A-17

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:46:49 +01:00
jschoubben 846291069b GATE17, GATE11: the gate removes a record by the runtime's ledger removal and reads the plain fill; settle and its ask machinery go
GATE17: record.go Remove asks buscalls.LedgerRemove, recordfill.go reads buscalls.Fill, the manifest names ledger-upkeep-calls.
GATE11: the verb settle, its ask, EvSettled and the Settle field leave with the merge states.

Builds: design 47 G §587, §431

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:44:05 +01:00
jschoubben d786477a0d GATE7 (source part, §690): the gate's source answers a branch's source key, or the branches it keeps
The gate's source value is the contract's SourceByBranchValue and its key SourceKey(branch); the verb takes an optional
branch and answers branches alone when given none. The whole-module package does not compile on the join for GATE11
(settle) and GATE17 (ledger fill and remove), so the unit suite was run with those five sites stubbed in a scratch edit,
never committed: green apart from the stubbed removal case.

Builds: design 47 G §690, §614, §15

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:35:26 +01:00
jschoubben b90d3a4556 The delivery gate names the ledger's fill answer as the sdk has it: buscalls.Fill
The sdk (02bea66) answers the ledger's fill call as one buscalls.Fill; the gate's LedgerFilled is a name that is gone.

Builds: design 47 G §462

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:26:20 +01:00
jschoubben 9c1cb63c3b mesh-provisioner uses the catalog key's codec meshcatalogkey, as meshcatalogclient's wrappers go
Builds: design 47 G §670

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:18:40 +01:00
jschoubben fa0f851f81 mesh-build-go, mesh-build-image and mesh-build-node read a step key's node as SD4 has it: ParseStepKey answers asker, node and id
Builds: design 47 G §484, §649

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:16:18 +01:00
jschoubben 65570636b2 Merge branch 'feat/47-G-conditions' into feat/47-G-dev2-join 2026-11-11 21:06:13 +01:00
jschoubben 1af839fb72 COND9 (moved-stream half): the keeper keeps a moved part's conditions, drops the old source and asks once on the new stream
Builds: design 47 G §663, §690

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 20:44:44 +01:00
jschoubben ecf597adce COND11 (in part): the keeper's ask bound and first-view bound are settings read at its start; the two hours wait on COND7
Builds: design 47 G §557

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 20:32:33 +01:00
jschoubben 811b59b454 COND9: the keeper takes a part whose finding stream the bus module made as a source (the moved-stream half stops on a question)
Builds: design 47 G §515, §663

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 20:27:47 +01:00
jschoubben 8f4f70f088 COND6: the keeper follows one catalog view per watched branch and adopts a branch registered while it runs
Builds: design 47 G §365, §479, §521, §532

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 20:26:40 +01:00
jschoubben ef60e5d09e COND2 (whole): the keeper's whole test starts with a kept source and no view of the catalog half
Builds: design 47 G §388

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 15:49:13 +01:00
jschoubben 26c82a507a OBS4: artifact-store-tools, mesh-watcher, minio and nfs-server ask findings and finding-events in requires
Builds: design 47 G §529, §387

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 15:48:35 +01:00
jschoubben 76be864bff HEAL2 (manifest only): mesh-healers asks findings and finding-events in requires; the writer's use waits on the contracts' finding subjects
Builds: design 47 G §529, §384, §461

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 15:48:35 +01:00
jschoubben 9e2311029a COND2: the keeper keeps its sources in the state findings-views and hears them before its first view of the catalog half
Builds: design 47 G §388, §365

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 15:46:35 +01:00
jschoubben bea1c446ea NATS10: the bus module serves no restore point tool and keeps no state kept
Reverts bbd9f638, c2e017a4 and b6884796; cmd/mesh-nats-snapshot/restore.go is as before them. The manifest test
asserts the claim serves only the reading verbs and the keeper's state is asked, requests, spent.

Builds: design 47 G §554, §555

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 15:45:03 +01:00
jschoubben 1e55ea8e4e Merge branch 'feat/47-G-build-conditions' into feat/47-G-conditions 2026-11-11 15:44:22 +01:00
jschoubben 9f62e0a2ba BLD6: the real unit level's broken fixture fails in its tests alone, go vet being the lint step's
Builds: design 47 G §328, §417

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:59:16 +01:00
jschoubben 579cd33cd5 GATE2: the whole-module test counts the merges handed in the state merges, the merge as an owed act being left out of the join
Builds: design 47 G §160, tests 34, 39 (eb131beb left out, G §167, §542)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:55:49 +01:00
jschoubben d3286ad0eb BLD3, BLD4: the joined step test carries no names list, the lint's fixed-name rule being taken out
Builds: design 47 G §7, §328, §329

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:53:43 +01:00
jschoubben a7b83b27f0 The issue tracker links a research effort by its number (milestone 47/G, W15.2)
The trace from research to the running build is links all the way (design 47).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from dd68c96d.)
Builds: design 47 G §318 (ISS1)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:48:07 +01:00
jschoubben 4839884d93 A decision links the milestone that builds it: link kind built-in (milestone 47/G, W15.2)
ADR 0493 section 4 names the links; the knowledge base took no milestone as a target.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 5f4e4d29.)
Builds: design 47 G §318 (KB1)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:48:07 +01:00
jschoubben 6d4ad2c249 Test that the operator's Approve says accepted, by whom and on which warrant (issue 595, W15.1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 65c6ca40.)
Builds: design 47 G §319, test 59 (KB1)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:48:07 +01:00
jschoubben 6f7d070769 M1-M5: the router holds back the asker's oldest ask behind a fourth and offers it again when one ends; the seat operator-channel's ask-again offers it now (milestone 47/G, issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW

ask-again is the seat operator-channel's verb (key in; state or error out), not the module tool messenger_ask_again, and records no hand act (G §394, §450).
(Joined from 681975ec.)
Builds: design 47 G §40, §83, §403 (RTR1, RTR2)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:48:07 +01:00
jschoubben 85023b3b52 gate_record_repair asks the owners' read verbs for the outcomes a record's node entries lack and applies them as the consumer would (milestone 47/G, gate_record_repair)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 46d3053c.)
Builds: design 47 G §159 (GATE13)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:45:57 +01:00
jschoubben 8484d349aa The gate says record-opened when it opens a delivery record (milestone 47/G, W9.3)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 649fa9b4.)
Builds: design 47 G §304, §305 (GATE)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:45:50 +01:00
jschoubben 1f1aa70580 The gate's whole-module test traces a delivery in one record and starts the gate again over the same bus and ledger: the record whole, nothing handed or asked again (milestone 47/G, W11.16: acceptance 34, 39)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from d162f899.)
Builds: design 47 G §160, tests 34, 39 (GATE2)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:45:50 +01:00
jschoubben 46c125c07c The gate keeps no mark of an own ask of a check: the dead field is gone and a row holds it (milestone 47/G, W11.8, issue 1794: its cause went with the gate's checks, issue 1318)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 70ded58f.)
Builds: design 47 G (main file G row: issues 1785, 1794) (GATE)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:45:50 +01:00
jschoubben 435d1206e4 A verdict, composed check or status of the same heads that changes what a refusal waited on starts the count of repeated refusals again (milestone 47/G, W11.7, issue 1785)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 80d401c4.)
Builds: design 47 G (main file G row: issues 1785, 1794) (GATE)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:45:50 +01:00
jschoubben 6fe1bba067 The gate hears the teller's answered and the view's reached, reads their answers and reach through their tools, and reads neither state (milestone 47/G, ruling 3: ADR 0596 §2-§4)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from ace22b9f.)
Builds: design 47 G §431 (the reads of the teller's answers and the view's reach are by their events); the commit's calls of teller_answers and view_reach are kept whole, the map names them no line

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:45:50 +01:00
jschoubben ed188268a0 The gate asks the ledger's calls and its fill of its machine's tool runner (milestone 47/G, W11.20 ruling)
The gate reads its records' ledger fill before it opens a record; its manifest asked no
capability, so it could run beside a runner answering neither. It now asks ledger-calls and
ledger-fill, the fill a capability of its own.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from c20044a2.)
Builds: design 47 G §181, §462, §488 (GATE12)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:44:20 +01:00
jschoubben bf6bb3d094 The gate names the issues linked in the issue tracker and the forge's author, and reads no description (milestone 47/G, W11.5)
ADR 0659 §4 kept three description reads that shape words only until they move to the bus: the
issue numbers the ask names, the author a stalled line names and the notice that after: lines are
not read. The ask now names the issues linked to each member's pull request, read once per head
through the seat issue-tracker's list (a tracker that does not answer is said, never waited on);
the author is the forge's pull request record's, from the seat git's events; the after: notice is
gone. The gate's forms of a pull request and a delivery hold no description field.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from ddd93a78.)
Builds: design 47 G §50, §143 (GATE15)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:44:17 +01:00
jschoubben 8ee28d08c3 The gate says its records' ledger filling, full and room, and holds a merge whose record finds no room (milestone 47/G, W11.20)
Design 47: a store filling is said before it is full. The gate reads its ledger's fill through the
tool runner's mesh/ledger.fill, says filling of the seat mesh-delivery when a record takes the ledger
past 80 % of its cap, refuses the next record when none fits and says full naming it, and keeps the
merge waiting for room on its delivery (held_full), so a restarted gate judges alike. Once the
ledger is below 80 % again, after the retention's removals, it says room and hands the merge.
gate_records says the fill against its cap, or why it is unread.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 061be00d.)
Builds: design 47 G §181 (GATE12)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:44:17 +01:00
jschoubben 6ac5c934d2 The gate holds no merge for a check: the hold, the turns, their stalled line and tools, and check-gates-merge go; the merge judges the operator's Approve alone (milestone 47/G, W11.14)
ADR 0661: the check gates the ask, in one place (askWaits); once the Approve covers
the heads (approvedWaits) no verdict, carry, composed check or required status is
judged, an Approve given before a trunk move merges onto the new trunk, and a
failure after the Approve is said once on the pull request and as a stalled line
naming the merge it follows. A check failing before the answer withdraws the ask.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 43696644.)
Builds: design 47 G §278, test 52 (GATE10)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:43:01 +01:00
jschoubben ee9c389888 The gate takes owed.ended and take-in.taken from its manifest's consumes and names neither the forge bridge nor the indexer in code (milestone 47/G, the names move)
The module's own manifest is embedded (package at the module's root); an event the gate hears from another module is
the consumes entry with that event's name after its owner. No new seat, no second publisher on the seat git (the
lead's ruling). Rows in the manifest test, which also reads the gate's sources for those names.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 2de87fd8.)
Builds: design 47 G §7, §146 (GATE16); the bridge's name stays in code for the old merge follower (eb131beb left out, G §167, §542)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:41:20 +01:00
jschoubben 59f9a00c64 A merge the forge made with no record's merge-pull opens a forge-made delivery record, said once as its transition with why forge-made (milestone 47/G, W11.17e)
The record holds the pull request at its head and has no Approve; the delivery keeps that it was recorded
(forge_recorded). Rows in issue 888's test. The test bridge never performs a switched act: the forge bridge decides.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 674f49ef.)
Builds: design 47 G §170 (GATE6)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:40:50 +01:00
jschoubben badfe0d166 The switch says every act the old states owed and merges hold under its switched id, so the forge bridge performs only what the old bridge did not (milestone 47/G, W11.17d carry)
Every key of owed, and the member merging in every key of merges, is said as owed under
meshdeliveryclient.SwitchedActID(state, key, revision), at every switch, the same act each time. The switch reads
the old states through one reader (OldState). Rows in the switch's test.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from dd0c4294.)
Builds: design 47 G §169 (GATE5, GATE6); only the owed carry: the member merging in the old state merges is not carried as a merge act (G §167, §542)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:40:34 +01:00
jschoubben 64ed69db9b The gate's groups answers the seat's groups: members in merge order, the acts said with their ids and the acts recorded with their outcomes, one group alone, and none for a group it does not know (milestone 47/G, W11.10; F-forge-7, F-forge-8)
The forge bridge reads a group's members and what it owes from here, never from the gate's state. The text form of
the members and composed_gate are gone with the answer of before.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from de368344.)
Builds: design 47 G §157, §304 (GATE5)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:37:37 +01:00
jschoubben c6cf8b64f1 The gate serves the seat mesh-delivery's verb source: each repository's trunk branch and head and the revision, read from source.main (milestone 47/G, source verb)
A node-engine reads the repositories here to call a reverted build's catch-up tool once per repository, never the
gate's state. Rows in issue 888's test and the bus test, which call the verb over the runtime.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from b45fd0ab.)
Builds: design 47 G §15 in part (GATE7)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:37:33 +01:00
jschoubben 32528aa58f An act owed to the forge with no outcome 15 minutes after it was said is listed by stalled (milestone 47/G, W11.17b)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 0fa400d5.)
Builds: design 47 G §166, §169 (GATE6)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:37:33 +01:00
jschoubben 00b95f1f68 Every act owed to the forge is said as the seat's event owed under one id and stays on its delivery's or group's state until its owed.ended is recorded (milestone 47/G, W11.17a)
At the gate's start every unanswered act is said again under the same id; an outcome is written into the record that
holds the pull request, then answered; an outcome other than performed is said; the checker's are not the gate's.
Needs the forge bridge's forgemsg owed.ended (feat/47-G-forge).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from cf3715ea.)
Builds: design 47 G §162, §163, §164, §165, §166 (GATE5)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:37:33 +01:00
jschoubben a9fa004758 The gate reads what the forge would merge now from the seat git's pull, so it names no forge tool (milestone 47/G, W11.18 follow-up)
The base's protection and required contexts, the forge's conflict word and the merging
account's write access come from the pull answer's merging (issues 1275, 1945; ADR 0441 §1);
a required context not a success at the commit blocks.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 13e12d2c.)
Builds: design 47 G §252, §310, §312 (GATE14)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:37:33 +01:00
jschoubben eb7a25622d The delivery record's tools read a record whole, list them with their fill and end one by hand; a removal that kept its operations is said (milestone 47/G, W11.13)
gate_record, gate_records, gate_consumer and gate_record_end (recorded in the hand-act log
of the seat mesh-hand-acts before anything is ended). gate_record_repair waits for the
owners' read verbs.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 12032a28.)
Builds: design 47 G §159 (GATE13)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:37:33 +01:00
jschoubben 8a6827dfe2 The delivery record says pinned, freed, heads-replaced and record-ended; it ends once every present node runs its entries (milestone 47/G, W11.9)
A node away is named with since when and holds nothing; a node away that wakes adds its
outcomes and is no longer shown away. freed is said before a record past its retention is
removed, so no revision stays pinned by nobody.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 52b629c6.)
Builds: design 47 G §147, §149, §153 (GATE3)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:37:33 +01:00
jschoubben 30a13c4ebb The gate reads and hears the forge through the seat git (milestone 47/G, W11.18)
The gate named the forge module's tools and events and guessed their answers' shape (operator (d), F14).
Its reads now are the seat git's verbs pull, statuses, open-pulls and contains, typed in the seat's
contract, a failed read an error and never none; its forge events are the seat's pull.updated,
pull.merged, pull.closed, forge.pull, forge.status and repo.writable, read into the gate's own forms
in one place (gitseat.go). What the forge would merge now (the base's protection, its conflict word,
whether the merging account writes) has no verb of the seat yet: it stays the one read of the
forge's statuses tool, named as such (mergeStateTool), until the seat carries it.

The bus tests' runtime now serves the gate's ledger, so the two whole-bundle tests that failed
since W11.1 pass again.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 14338514.)
Builds: design 47 G §252, §310, §312, test 51 (GATE14)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:35:58 +01:00
jschoubben 11daf2f289 The switch carries every group in flight into its delivery record, once (milestone 47/G, W11.3)
Design 47 (The switch carries today's state in, once): at the gate's start, before any event is
taken, each group in flight becomes a record keyed by its milestone and its heads (the heads its
merge was approved at when merges holds one), with that merge as its group state; a second switch
finds each record there and opens none, so a restart or a delivery of milestone G again after a
revert asks nothing again. From then on every merge the gate hands is written into its record too,
so the record's outcomes are found by its members' merge commits.

Left for W11.17 and W11.14: the merge handed as an owed merge-pull instead of the merges key, the
owed keys carried, and merges and turns no longer declared.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from e9415814.)
Builds: design 47 G §156, §220 (GATE4)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:35:57 +01:00
jschoubben 3f25e5b4da The delivery record hears the step events through the gate's own consumer (milestone 47/G, W11.2)
Design 47 (The delivery record): the outcomes arrive as events through the module's durable
consumer, each applied once and only from its owner's account. A family per step event names the
pattern the manifest consumes, its owner and whether its subject names a node; an event is applied
only when its key names its owner and, for a node's event, its x-node is the node its subject
names. It is taken once its outcome is in every open record holding it (by a member's merge
commit, or by the entry of the record's graph difference), so one the ledger did not take is
offered again; an unreadable one is said and taken. The indexer's take-in.taken is the first
family; each other step's event joins the table with its owner's contract.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 4c4d1239.)
Builds: design 47 G §34, §141 to §146 (GATE2)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:35:57 +01:00
jschoubben cb5a8dd98e The gate keeps one delivery record per milestone's delivery on its ledger (milestone 47/G, W11.1)
ADR 0656 §7 and design 47 (The delivery record): the gate asks the bus module for a ledger
`records` (template ledger, capped at 768 MiB) with two types: a record, keyed by its milestone and
its heads, whose operations open it, write its group state, trace, outcomes, nodes away, pinned
revisions and end, and remove it; and a month's list of the records opened, since a bundle cannot
list a ledger's instances. An open of heads already recorded leaves the record as it is, an outcome
applied twice writes nothing, and each open removes the records past the setting record-retention
(90 days by default), naming each revision they pinned. The layout is the gate's own (design 20).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 84eda463.)
Builds: design 47 G §141, §142, §143, §144, §145, §146, §155, §34 (GATE1)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:35:53 +01:00
jschoubben d3781fd840 W12.5: the three build modules answer kept-artifact with the domain SDK's judgement (issue 1966)
Design 47 lines 1888-1906 at b229f5c4 (test 60): the verb sits in every build seat's definition.
Its judgement (what may be kept, the kept builds a node holds, the three refusals) is shared code of
one domain's modules, so it is the domain SDK's (sdk/buildstep KeptArtifact, ADR 0634 §2):
mesh-build-go keeps its store, sweep and tools and drops its own copy of the judgement and its table;
mesh-build-node and mesh-build-image keep no build and answer over none, so a module claiming no
oci-artifact-registry is refused and the holder is answered not kept. Their manifests serve the verb
and invoke seat:mesh-catalog.module, which the judgement asks.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 42d364fe.)
Builds: design 47 G §271, §464 (BLD9)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:35:53 +01:00
jschoubben cf383af441 W12.5 (Go): mesh-build-go keeps the store holder's refused build and serves kept-artifact (issue 1966)
Builds design 47 *The artifact store's holder takes its own new build even
when its seat is the one broken* (lines ~1811-1830, test 60): a trunk build of
a module claiming oci-artifact-registry that the seat refuses or does not
answer is kept as an object by digest and answered kept (BuildAnswer.Kept);
kept-artifact answers its store and object, refusing a module that claims no
oci-artifact-registry, one not kept here and one under another digest. The
store is the template objects (ADR 0666 §4: a part outside the foundation keeps
its data in ADR 0595's templates; design 47's "old objects form" contradicts
it for a non-foundation part, reported). Whether a module claims the seat is
asked of the seat mesh-catalog's verb module (ADR 0596). Callers are left to
the seat definition and the grants part (ADR 0666 §20). build_go_kept reads the
kept builds, build_go_kept_drop drops one (ADR 0666 §25).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 5e1cdade.)
Builds: design 47 G §271, test 60 (BLD9)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:34:53 +01:00
jschoubben bb1e18f490 K1: mesh-build-go reads no kept state of the checker; the step ask carries the kept results (issue 1966)
A test step runs only the packages with no kept result for their digest, and the runner makes
that carry in the checker's place (ADR 0666 section 8; design 47 *Carried, not run* line 593 at
hq b229f5c4): the kept results reach the step in its ask (StepRun.kept, mesh-delivery
dc0df1b5), so the module's reads lose mesh-delivery-checker.kept and the lookup of the
checker's kept bucket goes. The carried and kept packages are in the step's end, on its step
key.

Milestone 47/G, research 086 area C (K1).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 1c991294.)
Builds: design 47 G §25, §27, §66, §416 (BLD5)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:33:05 +01:00
jschoubben 663d65640d A-C3: gofmt and vet are the lint step's, not the unit level's (issue 1966)
Lint is a pipeline step of its own, served by the build seat (ADR 0666 section 7; design 47
*The steps and who serves them* line 571 at hq b229f5c4), so the unit level runs tests alone
and the lint step runs gofmt and go vet, with every tag the files name, over each go.mod
directory of the artifact's module directory; a module directory holding no go.mod has
nothing to lint (prepare finding F12).

Milestone 47/G, research 086 area A.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from a46e01a2.)
Builds: design 47 G §328, §417 (BLD6)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:32:41 +01:00
jschoubben 119e0a6f13 A-C2: a build module runs the pipeline step kind step and says its end on its node (issue 1966)
The runner asks a lint or test step of an artifact as the build seat's accepted kind step, and
hears its end as the seat's step-ended on the node that ran it (ADR 0666 sections 7, 8, 21;
design 47 *The steps and who serves them* line 571 and *Asked on a work queue, ended by an event*
lines 583-590 at hq b229f5c4). The build modules' shared package runs a test step as a check of
its one level, carries the packages whose digest the ask names kept, keeps what it passed and
says the counts; a level with no tests ends nothing to run, a source fault could not run, and a
transport fault or an end that could not be said is handed back unsaid, so the work queue gives
the step again and it ends once under its ask's id. The neutral forms are the domain SDK's
(sdk/buildstep pipelinestep.go, mesh-delivery dc0df1b5); the Go module says the end through the
runtime's seat publish.

Milestone 47/G, research 086 area A.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 72fee3c6.)
Builds: design 47 G §22, §24, §86, §116, §140 (BLD3, BLD4)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:32:36 +01:00
jschoubben 202c44c6e3 Merge branch 'feat/47-G-build-bus' into feat/47-G-build 2026-11-11 14:32:02 +01:00
jschoubben 7a1e8d1036 Merge remote-tracking branch 'origin/main' into feat/47-G-build 2026-11-11 14:32:02 +01:00
jschoubben 299f43167c GIT12, REG1: the reservation check's test is a case of the holder's own test, so no module ends over its start count
Builds: design 47 G §381; test budget

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:15:01 +01:00
jschoubben 713b59633e REG1: distribution checks a publish against the builder's reservation, asked by the artifact's name
The container image and OCI artifact seats' holder asks the seat mesh-builds' verb reserved in place of builder_versions; the manifest
invokes it and takes no builder package.

Builds: design 47 G §381, §478, §504

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:05:41 +01:00
jschoubben 86342c4399 GIT12: the forge module's package registry checks a publish against the builder's reservation, asked by the package's name
The holder asks the seat mesh-builds' verb reserved with the publish's name and version in place of builder_versions, and stores
only a version reserved for an artifact of the publish's digest; the manifest invokes the verb and takes no builder package.

Builds: design 47 G §381, §478, §504

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:04:56 +01:00
jschoubben bc2e5574ef GIT7, GIT8: the forge module keeps the runner's failed required steps and refuses a head by them; git_failed_steps and git_failed_steps_drop
merge-pull refuses a head at whose fingerprint a run failed a required step (policy-failed, naming run, module, step), from the
module's own state failed-steps kept from step-ended, with the seen keys of every step end and the deletion once no watched
branch's head nor open pull request's head names a commit. The two tools read it with its fill and drop a key after the hand act.

Builds: design 47 G §39, §83, §84, §85, §135, §475, §487, §548

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 14:01:27 +01:00
jschoubben a24f7809a6 COND6 (partial, not landed): the keeper's sources, holder and clears from the catalog half and the assignment half
The fixed Observers list and the engineModule constant are removed; sources are the modules whose catalog entry declares the
finding events, a module in no branch's revision is cleared (module left the catalog), a stopped module no longer placed is
cleared (module left the node), Keeper.Holder is derived from the entry's claims. The per-branch catalog followers in main.go
wait on a design question (how a part adopts one catalog view per registered branch); the whole integration test is red until then.

Builds: design 47 G §365, §196, §200, §364, §384

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:28:45 +01:00
jschoubben 6062feaa22 NATS3 (part, branch-independent): which module sends, and a catalog entry read for the bus module
Sends reads a claim of a seat whose definition has a verb or an event, or an event of its own; AsksOf reads
an entry's bus-state asks and claims. The follower's branch and the wiring wait on the design's answer.

Builds: design 47 G §344, §433

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:23:40 +01:00
jschoubben 076f63f932 COND5 (go.mod): the keeper's go.mod requires the watchdogs seat package it imports
The repository check requiredmodules refused events.go's import of the mesh-watchdogs seat.

Builds: design 47 G §389

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:18:25 +01:00
jschoubben fc90e6c67b OBS1 (artifact-store-tools, mesh-healers, mesh-vault, mesh-watcher, minio, nfs-server): the finding stream in each manifest
Each of the six declares the stream <module>-findings (finding-written.*, finding-cleared.*,
findings-stated.*, 7 days, 16 MiB) and consumes mesh-conditions.findings-asked; the manifest
tests that hold consumes say it. The other observers' manifests are written by their own families.

Builds: design 47 G §387, §383

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:17:03 +01:00
jschoubben 5c8f3b8351 COND5: the keeper hears a machine away and back from the watchman
An observer on a machine away is not named in findings-unstated; a machine back has its
observers asked again (mesh-watchdogs machine-away and machine-back in consumes).

Builds: design 47 G §389

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:11:54 +01:00
jschoubben 405729127d Merge branch 'feat/47-G-build' into feat/47-G-build-conditions 2026-11-11 12:10:08 +01:00
jschoubben ca02942a29 COND4: the keeper's ask, resync naming a module, findings-unstated and the findings view tool
resync writes the hand-act log, says findings-asked for every observer or the one named and
settles what it holds; an observer silent five minutes after an ask (bounds state, key time
to live), its machine not away, is the keeper's finding findings-unstated of scope module,
cleared by its next findings-stated; conditions_findings_views answers each observer and node.
The nodes asked are those heard (the kept list of COND2 is stopped on a question).

Builds: design 47 G §390

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:10:08 +01:00
jschoubben 6acc747661 Merge branch 'feat/47-G-build' into feat/47-G-build-bus 2026-11-11 12:08:12 +01:00
jschoubben b6884796e7 NATS4: the bus module takes and restores a restore point of a module's kept data, beside the live data
Verbs snapshot-kept and restore-kept (with aside-only and discard-aside) on the seat mesh-broker, handed to the
keeper; taken over from the join's 0480e16f and c4e88eaa, without the verbs aside and discard-aside and the tool
nats_kept, which no design line gives.

Builds: design 47 G §296, §297, §302, §337, §405

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:08:05 +01:00
jschoubben fcff8cf35e Merge branch 'feat/47-G-build' into feat/47-G-build-conditions 2026-11-11 12:07:49 +01:00
jschoubben a96fd44abb COND3: the keeper sets its view of an observer from what it states
findings-stated raises what is new, keeps each pending key unchanged, clears every other
condition of the observer on that node with its reason, clears a pending key past its bound
(bounds state, key time to live), and for an observer held once takes the holder's set and
later events only. The holder lookup is a hook (Keeper.Holder) that COND6 wires from the
catalog; the view is kept in memory until COND2 keeps findings-views.

Builds: design 47 G §384

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:07:49 +01:00
jschoubben c2e017a4c1 The bus module takes and restores a restore point of a module's kept data, beside the live data (milestone G, W14.2)
Seat verbs snapshot-kept, restore-kept, discard-aside and aside, handed to the keeper; state kept; tool nats_kept.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:05:54 +01:00
jschoubben bbd9f638a0 Move the restore of a bus archive into bussnap so the keeper restores a kept data too (milestone G, W14)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:05:54 +01:00
jschoubben d64e9f3818 Merge branch 'feat/47-G-build' into feat/47-G-build-bus 2026-11-11 12:05:16 +01:00
jschoubben b9a224dc99 NATS2: the bus module plans each sender's sent store, 256 MiB, 7 days, refusing when full
The store is made by Keeper.Sends, kept when a verdict asks nothing of it, and a full one says `full`.
Which module sends is NATS3's input (the catalog half); this makes the store.

Builds: design 47 G §59, §137, §344, §477

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:05:11 +01:00
jschoubben b8c3f5f09a COND1: the keeper hears findings as events and reads no observer's state
The keeper takes finding-written and finding-cleared from each observer's stream
(`*.finding-written`, `*.finding-cleared`, `*.findings-stated` in consumes); its reads of
every `findings`, the views.findings consumes and the read_findings invokes go.
node-engine's name is a local constant since mesh-sdk's buscompose lost it.

Builds: design 47 G §383, §386

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 12:05:00 +01:00
jschoubben e6aab55008 GIT15: the forge module guesses no base branch for a pull request the forge gave none
The base "main" fallbacks of the open-pulls read and the announcer are gone (the indexer's module name and tool went with GIT10).
The manifest's version is MAN1's, written with the fill's source it needs.

Builds: design 47 G §7, §428

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:44:59 +01:00
jschoubben 312d824e4f GIT13: the forge module keeps no trusted callers; who may call a verb is the seat definition's
The config value callers, the package seats' list of callers with its watch, and the seat git's caller judgement are gone:
the grants part grants by each definition's callers and the bus refuses the rest.

Builds: design 47 G §68, §127, §313, §321

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:43:18 +01:00
jschoubben 5daf3a333a GIT11: the seat git's tag writes go/vMAJOR.MINOR.PATCH at the commit a released version was built from
A tag at that commit is answered performed; at another commit, refused (a tag is never moved). The builder alone calls it:
the bus refuses any other account from the definition's callers.

Builds: design 47 G §370

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:37:36 +01:00
jschoubben a00429bc80 GIT6, GIT9: merge-pull merges the head the caller names and asks no Approve; it refuses every merge until the first registrations
merge-pull answers its refused-kind (head-moved, unprotected, git-refused, no-registrations); an already merged pull request at
that head is answered performed. The failed-steps refusal (policy-failed) is GIT7, stopped on a design question.
GIT9: registrations.ts learns the first registrations from the seat mesh-catalog's verb registrations at the start and its event
registration-changed; the code artifact needs mesh-delivery-assigner:program.

Builds: design 47 G §38, §129, §314, §95, §96, §130, §353, §432

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:36:37 +01:00
jschoubben 975f1aa8e8 GIT5: the forge's push webhook of a branch says branch-moved with the new head on the seat
Builds: design 47 G §11, §111, §331 branch-moved

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:32:33 +01:00
jschoubben 0bf636826a GIT4: the seat git's branch answers a branch's head and whether a rule refuses a direct push and admits only merges
Builds: design 47 G §5, §111, §310 branch

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:32:33 +01:00
mesh-tools 9dfb2eae0e Merge pull request 'Registry recovery: the bus module's list, show and orphans answer each binding (issue 1966)' (#656) from fix/1966-bindings-first-view into main 2026-11-11 10:32:25 +00:00
jschoubben b471b5cf9e Merge branch 'feat/47-G-build' into feat/47-G-build-forge 2026-11-11 11:31:27 +01:00
jschoubben 32655b95f6 GIT1, GIT2, GIT3, GIT10: the forge module serves the seat git's verbs and says its events, retires its acting tools, follows the catalog half
The four share files (index.ts, module.json, guard.test.ts), so they land as one commit.
GIT1: seat.ts serves pull, open-pulls, tags, trees, file, contains, statuses, status, comment, view, note, merge-pull
as tools on the seat, each answering the contract's shape and an error of kind refused, not-found or failed; a comment,
view or note whose act id is held is answered held (note matched by id). merge-pull merges the head the caller names
(its from-G judgements are GIT6, GIT7, GIT9).
GIT2: seatevents.ts says pull-updated, pull-merged, pull-closed, forge-pull, forge-status, forge-delete and repo-writable on
mesh.seat.git.event.<event>; the module's emits drop them.
GIT3: merge.ts, check.ts, the acting tools, the owed watch, the gate's source read and check-gates-merge are gone.
GIT10: catalog.ts follows the seat mesh-catalog (revision, catalog-revision); no write is granted before the first view.

Builds: design 47 G §310, §311, §312, §313, §314, §163, §317, §278, §321, §196

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:31:24 +01:00
jschoubben b5aebbbe91 GEN4: the seat generator emits the types of bodies, resolves #/bodies/ refs and names an accepted kind by its stream
Builds: design 47 G-9 §452, §454, §455 (§391: no verb is refused for a missing class)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:27:17 +01:00
jschoubben 4ad5f40817 BLD7, BLD8: the Node and image build seats serve the verb steps
Node answers the type-check as lint always, unit where a package has a test
script and integration where it has test:integration, kept in detected-steps by
fingerprint as the Go module keeps it; the image answers none. The manifests
serve it. The step ask's end (step-ended) of BLD7 and BLD8 waits for BLD3 and
the build-step package of mesh-delivery-sdk.

Builds: design 47 G §22, §307, §326, §328

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:06:40 +01:00
jschoubben b7dab47e88 BLD15: the Go build module declares its finding stream and hears the keeper's ask
Part of BLD15: the manifest's stream mesh-build-go-findings (the three
finding events, 7 days, 16 MiB) and the consume of mesh-conditions.findings-asked.
The module's writing of findings through mesh-sdk's finding writer waits for
the writer (the writer's API is an open design question, D-16).

Builds: design 47 G §383, §384, §387

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:03:11 +01:00
jschoubben dfb682fa33 BLD14: a run's input is asked of the verb the asker's ask names
The Go build module asks the input verb in the ask (the checker's, the
indexer's mesh-catalog input or the assigner's mesh-wants input) and its
manifest invokes the two seats' verbs. The bus refuses the other accounts
(grants of the contract's callers). The step key keeps the contract's form
<node>.<id>: no asker part is named there (question to the lead).

Builds: design 47 G §125, §206, §338, §354

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:02:03 +01:00
jschoubben 357967308f BLD11: the three build modules take the version from the ask as MESH_VERSION; version.numbered is no longer heard
The Go compile, the Node tsc/esbuild/npm and the image's buildah bud get the
build argument MESH_VERSION from the version the build ask carries. The late
publish on version.numbered goes (code, tests, the consumes of the Go and
image manifests). The image's label and tag and a program's stamp are open
(question to the lead): the design names no key or symbol.

Builds: design 47 G §349, §370, §413

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:00:52 +01:00
jschoubben 25bbd55539 BLD2: the Go build seat keeps the answer of steps in detected-steps by fingerprint
Part of BLD2 (Go module): the state detected-steps asked by the template
state, one key per artifact, answered for the same fingerprint, replaced by a
newer one and by again. The drop of a key and the Node and image modules wait
(drop: question to the lead; the others follow in BLD7 and BLD8).

Builds: design 47 G §326, §414

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 10:56:55 +01:00
jschoubben cfad86394f BLD1: the Go build seat serves the verb steps, found by Go's convention
The verb fetches the artifact's tree and answers lint always, unit and
integration steps by the packages holding test files (integration by the tag),
a level with no test left out; the manifest serves it.

Builds: design 47 G §22, §307, §28

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 10:54:55 +01:00
jschoubben c62650e4ff GEN3: the seat generator reads acts, subject, keep and callers and writes each event's subject and stream
An event whose subject carries parts is published by a function taking each part and heard by a filter with a * per
part; an event naming keep gets the stream SEAT_<seat>_<event>, in Go and in the TypeScript contract. The reader of the
sdk's reader release reads acts (every, a language, kind:image), keep as an object and callers as a list.

Builds: design 47 G §89, §203, §238, §260, §324, §331, §396, §397

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 10:40:42 +01:00
jschoubben fe8c41928d GEN2: the seat generator emits each TypeScript contract as index.js with its index.d.ts
A seat's npm package holds index.js, index.d.ts and parts/<part>.js with .d.ts, with no TypeScript source and no
tsconfig.json, so a TypeScript module imports it with no build step and no tsc of the contract.

Builds: design 47 G §341

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 10:37:07 +01:00
jschoubben 50677b204b GEN1: the seat generator refuses a definition whose callers entry is no form of the format
The generator checks each verb's and accepted kind's callers in a definition and a part before reading it, naming the
file, the verb and the entry; text is refused as one entry; the contracts it writes carry no caller check.

Builds: design 47 G §336, §339, §392

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 10:35:33 +01:00
jschoubben ae056a44dd The bus module's list, show and orphans answer each held entry's binding
mesh/delivery merged: source.main moved to novox/mesh-catalog@9dfb2eae0eb3
mesh/delivery-group group fix/1966-bindings-first-view merged: every member is merged
mesh/check checking ae056a44dd72 on 9dfb2eae0eb3 | novox/mesh-catalog#656 in group fix/1966-bindings-first-view
Held.binding is the entry's plan binding, the value its created and recreated
carry, present when the bus holds the entry's objects and absent on one it does
not (waiting, refused). Table rows in the reading verbs' test: the binding of a
held entry equals the one its created carries; a waiting entry has none.

Builds: design 25 bindings-first-view §2, §7 (hq d0760629, PR 543)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 10:19:42 +01:00
mesh-tools ea74be9e2e Merge pull request 'The Go fetch fails when its shut-off package load cannot run at all, naming the directory and go list's error (issue 1966)' (#655) from fix/1966-shutoff-load-loud into main 2026-11-10 15:42:10 +00:00
jschoubben 19994c1817 The fetch fails when the shut-off package load cannot run at all, naming the directory and go list's error (issue 1966, AC182-1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@ea74be9e2e10
mesh/check checking 19994c1817bb on ea74be9e2e10
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 16:37:40 +01:00
mesh-tools 3c0b4d29a8 Merge pull request 'Fill gitea's pool keys in the provisioner client's gitea tests, which fail on main since 4becc7bd (issue 1966)' (#653) from fix/1966-provisioner-client-pool-config into main 2026-11-10 14:49:01 +00:00
mesh-tools 7cc81512a3 Merge pull request 'The Go fetch takes zero-version requirements from the module graph and proves them with a shut-off load; mesh-build-image and -node name nodebuildgo (issue 1966)' (#654) from fix/1966-fetch-module-graph into main 2026-11-10 14:48:41 +00:00
mesh-tools 462db1c257 Merge pull request 'Play the config watch in the package seats' whole test, so the held callers list is proven (issue 1966)' (#652) from fix/1966-registry-test-watch into main 2026-11-10 14:40:51 +00:00
jschoubben 4a3914ee1c S4: the fetch proves with a shut-off package load that it downloaded all a step loads (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@7cc81512a389
mesh/check checking 4a3914ee1ce7 on 3c0b4d29a8ea
go mod download loads only the module graph a go.mod names; a vet, a test or a build then loads
packages shut off from the network and failed far from the fetch, in fewer words (the builder's
"pull_drop.go:11:2: module lookup disabled by GOPROXY=off"). After each directory's download the
fetch loads its packages with their tests shut off and fails naming each package whose load
would reach the network. The graph of a sibling laid beside is loaded once with the network
first, so a go.mod the composer's build reads and the graph's download skipped is fetched, then
proven the same way.

1966/AC178-2: a row of TestAVerifyThatFailsEndsTheStepNamingTheDirectoryAndTheLine; the beside
case in TestTheFetchDownloadsTheGraphOfASiblingModuleLaidWithTheTreesItRequires.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 13:10:44 +01:00
jschoubben ce3284fc8f S3: the fetch takes a module's zero-version requirements from its module graph (issue 1966)
ADR 0573 §2 builds every module against the latest published version of each mesh package it
uses, whatever its go.mod names. The fetch took versions only for packages the directory's own
go.mod names, so a package only a module it replaces by a directory requires (mesh-build-image
replacing buildstep, which imports the node-build-go seat package) was never taken: go mod
download passed and every shut-off step looked it up on the network. Now the requirements of
each module the directory replaces by a directory count too, taken the same way (the ask, its
repository's asked head, else the latest) and said as named by that directory.

1966/AC178-1: rows of TestARequirementAtTheZeroVersionIsTakenFromTheAskOrTheTrunk.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 13:08:00 +01:00
jschoubben ad6e813fb0 S2: mesh-build-image and mesh-build-node name nodebuildgo, which buildstep imports (issue 1966)
Since buildstep's drop verb imports the node-build-go seat package, both modules' shut-off vet,
test and build looked it up on the network: their go.mod did not name it, so the fetch took no
version for it. go mod tidy adds it as an indirect requirement.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 13:04:39 +01:00
jschoubben b373e34b29 Fill gitea's pool keys in the provisioner client's gitea tests (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@3c0b4d29a8ea
mesh/check checking b373e34b29ec on 3c0b4d29a8ea
The template gained three config keys the tests' bus held no values for, so the client rightly never wrote server.env.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 12:51:37 +01:00
jschoubben 067b9a47da Play the config watch in the package seats' whole test, so the held callers list is proven (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@462db1c25719
mesh/check checking 067b9a47dafd on 462db1c25719
The holder holds its callers by a config watch since the callers were moved off per-call reads; the test's player answered only provision.get.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 11:00:57 +01:00
mesh-tools bfa4a0851a Merge pull request 'Review parts are asked one at a time: the node judges one at a time (issue 1966, quick fix)' (#651) from fix/1966-kb-one-part into main 2026-11-10 07:33:48 +00:00
jschoubben b7f78d47c5 Review parts are asked one at a time: the node judges one at a time, a second ask only spent its bound waiting (issue 1966, quick fix)
mesh/delivery merged: source.main moved to novox/mesh-catalog@bfa4a0851acf
mesh/check checking b7f78d47c5c4 on bfa4a0851acf
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW
2026-11-10 03:00:58 +01:00
mesh-tools c4463ba47f Merge pull request 'The condition keeper reads the node-engine's findings; claude-code's seat-list again answers the operator's sessions (issue 1966; group fix/1966-seat-list-unread)' (#650) from fix/1966-seat-list-unread into main 2026-11-10 00:44:30 +00:00
jschoubben c39503e322 S7: claude-code's seat-list again answers a proved operator's session
mesh/delivery merged: source.main moved to novox/mesh-catalog@c4463ba47fcf
mesh/delivery-group group fix/1966-seat-list-unread merged: every member is merged
mesh/check checking c39503e3227c on c4463ba47fcf | novox/mesh-catalog#650 in group fix/1966-seat-list-unread
claude_code_seat_list_again answered person.<name> and the healers, but every
call through the mesh MCP server is proved as its machine's tool runner
(issue 627); node-tools now stands for that session (issue 1966).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 00:59:07 +01:00
jschoubben 6c61d9facf S6: the condition keeper reads the node-engine's findings
ace's node-engine wrote seat-list-unread in its state findings, and no
condition rose: the keeper's observers did not name the node-engine. It now
reads mesh-host.findings, so ADR 0636 §1's condition is raised and clears at
the first full read (issue 1966).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 00:49:55 +01:00
mesh-tools a39b72c377 Merge pull request 'The build modules end a transport fault's run again, serve drop, and are held to the transport-fault classes (issue 1748; group fix/1748-transport-retry, merge last)' (#649) from fix/1748-transport-retry into main 2026-11-09 22:58:43 +00:00
jschoubben e712cd227f C5: the integration row reads the dropped step as dropped (ADR 0664 §4, issue 1748 review 1)
mesh/delivery-group group fix/1748-transport-retry merged: every member is merged
mesh/delivery merged: source.main moved to novox/mesh-catalog@a39b72c377a5
mesh/check checking e712cd227f37 on a39b72c377a5 | novox/mesh-catalog#649 in group fix/1748-transport-retry
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:44:08 +01:00
jschoubben 12059ad3cc F5: the failurekit check refuses a build module whose manifest does not ask seat-settle-delay (ADR 0664 §5, issue 1748 review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:42:46 +01:00
jschoubben 6a29e41175 F3: mesh-build-go and mesh-build-node follow the drops other holders write, and a module with no watch says so (ADR 0664 §4, issue 1748 review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:42:13 +01:00
jschoubben 1948da9cb4 F8, dropped: the build module's drop uses the generated contracts' Drop and ends the step dropped, with the named cause (ADR 0664 §4, issue 1748 review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:41:10 +01:00
jschoubben b84a2f4dc1 F6b: merge main (#647) into the transport retry: unknownRevision reads the transport words, the row says passing (issue 1748 review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:33:33 +01:00
mesh-tools 9c42423d58 Merge pull request 'The knowledge base's review knows its round: from round 3 only a wrong record asks changes, at round 5 the operator decides (issue 784)' (#648) from fix/784-review-round into main 2026-11-09 22:26:59 +00:00
jschoubben c12f1a16db get shows a round only for a proposed version (issue 784, ADR 0665)
mesh/delivery merged: source.main moved to novox/mesh-catalog@9c42423d5821
mesh/check checking c12f1a16db83 on 9c42423d5821
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW
2026-11-09 23:22:46 +01:00
jschoubben 09ea4a40bf A closing keeps the review round, get says the round, a close waits for the operator's round-5 answer (issue 784, ADR 0665)
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW
2026-11-09 23:20:22 +01:00
jschoubben 6b8e7fc621 C1c, C6: any holder serves drop and every node follows it by a watch of the shared pauses; the build modules serve drop and ask the tool runner's seat-settle-delay (ADR 0664 §1, §4, §5, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:19:28 +01:00
jschoubben 3c3fdd3446 C5: an integration test of the delayed redelivery and the drop of a running step against the real bus (ADR 0664 §1, §5, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:16:09 +01:00
jschoubben 06f28fbadb C4: the conformance kit gains Passing with the words of each runner when the network did not answer; each build module's tests call it and checks/failurekit refuses one that does not (ADR 0664, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:15:35 +01:00
jschoubben 62644aa012 The knowledge base's review knows its round: from round 3 only a wrong record asks changes, at round 5 the operator decides (issue 784, ADR 0665)
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW
2026-11-09 23:13:55 +01:00
jschoubben bdfd8db265 C3: mesh-build-node and mesh-build-image say a failure of the network as a transport fault (npm ci, buildah bud, the image seat's mirror) (ADR 0664 §1, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:13:45 +01:00
jschoubben 89ec9429db C2b: a seat verb's no responders is a transport fault when the assigner names a holder of the seat; a seat nothing holds fails at once (ADR 0664 §1, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:12:35 +01:00
jschoubben b9cfa4fe16 C2: mesh-build-go says a failure of the network as a transport fault: one download, no retry of its own (ADR 0664 §1, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:10:16 +01:00
mesh-tools 8b184d8322 Merge pull request 'mesh-build-go reaches the forge's git when the public proxy is unreachable, and a network failure is no pin (issue 1966)' (#647) from fix/1966-contracts-goproxy into main 2026-11-09 22:08:33 +00:00
jschoubben 02d57c4a53 C1b, C1c: the seat's verb drop refuses a dropped ask at pull, ends one that runs, and the start sweeps old drops (ADR 0664 §5, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:07:49 +01:00
jschoubben 5a7561fe1d C1: a build step that ends on a transport fault below its last run is written again and handed back to the bus with a delay (ADR 0664 §1, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:04:44 +01:00
mesh-tools 5e537f851f Merge pull request 'Distribution and the forge hold their caller list from its config watch, not an ask per call (issue 1966; group with mesh-sdk)' (#646) from fix/1966-callers-config-read into main 2026-11-09 22:00:48 +00:00
mesh-tools 0a6a5aba68 Merge pull request 'mesh-build-image names its copy wait's ownership mark nonce, so the state takes it and nats images are stored again (issue 1966)' (#645) from fix/1966-mirror-waits-token into main 2026-11-09 22:00:32 +00:00
jschoubben 407fc6c9e6 The Go seat goes on to the public proxy only on a 404, the public proxy to direct on any failure (issue 1966, review 1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@8b184d83222c
mesh/check checking 407fc6c9e625 on 9c42423d5821
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 22:55:53 +01:00
jschoubben 6299b47f4f A mesh package the Go seat lacks is fetched from the forge's git, not stopped by an unreachable public proxy (issue 1966)
GOPROXY joined the Go seat, the public proxy and direct with commas, so go went on only after a 404 or 410: a contracts
package the seat does not hold, and a public proxy a node cannot reach, ended the fetch before direct. It joins them
with | now, so any failure goes on to the next. go words a proxy it cannot reach as "invalid version", which the pin
path read as an unknown revision and labelled "on no pull request head"; a network failure is never that now
(network is unreachable and no route to host are network failures).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 22:42:09 +01:00
jschoubben 24bf59219e Distribution and the forge hold their caller list from its config watch, not an ask per call (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@5e537f851f08
mesh/delivery-group group fix/1966-callers-config-read merged: every member is merged
mesh/check checking 24bf59219ee5 on 9c42423d5821 | novox/mesh-catalog#646 in group fix/1966-callers-config-read
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 22:42:00 +01:00
jschoubben e3b4ecde8c Name the copy wait's ownership mark nonce, not token, so the state accepts it; the copy test's state refuses credential names as the bus does (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@0a6a5aba682e
mesh/check checking e3b4ecde8cf2 on 5e537f851f08
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 22:42:00 +01:00
mesh-tools fd9147087e Merge pull request 'The bus module reads a consumer's counts and a stream's events, so a lost event can be traced' (#644) from fix/1966-nats-consumer-read into main 2026-11-09 21:27:30 +00:00
jschoubben a3d141e793 nats_stream_messages shows a body only for the stream EVENTS (issue 1966, review 1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@fd9147087ef6
mesh/check checking a3d141e79351 on fd9147087ef6
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:21:03 +01:00
jschoubben 4fbe6e347f nats_stream_messages shows a body only on an event stream (issue 1966, review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:20:19 +01:00
jschoubben 3d70bd3660 nats_stream_messages refuses a module's kept state (KV_*, OBJ_*), which can hold secrets (issue 1966, review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:18:43 +01:00
jschoubben a4e5a8ffef Format the bus tools' new test (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:14:00 +01:00
jschoubben 13dea6b1aa The bus module reads one consumer in full and a stream's messages by subject (issue 1966)
A lost event between a shared stream and a module's consumer could not be located: no tool read a consumer's delivered and acknowledged sequence or a stream's messages for one subject. nats_consumer reads from the monitoring document; nats_stream_messages asks STREAM.MSG.GET on the module's own credential, newest first and bounded.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:13:02 +01:00
mesh-tools 6d2ce96630 Merge pull request 'The resolver module's tests hold its configuration's guards' (#643) from fix/1966-resolver-test into main 2026-11-09 20:09:34 +00:00
jschoubben a7dc4534d7 Hold the resolver's configuration guards in the dnsmasq module's tests (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@6d2ce96630f0
mesh/delivery-group group fix/1966-resolver-test merged: every member is merged
mesh/check checking a7dc4534d73a on 6d2ce96630f0
Moved from mesh-sdk's catalogue tests, which read a literal the file no longer has.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 20:59:46 +01:00
mesh-tools 4387e77f28 Merge pull request 'mesh-hand-acts answers bounded, newest first; mesh-healers seeds from a partial answer (issue 1966 S8, recovery)' (#642) from fix/1966-s8-hand-acts-window into main 2026-11-09 17:55:10 +00:00
jschoubben 443a340f06 Listing.Complete comment says what the field means now (issue 1966 S8 review 2)
mesh/delivery merged: source.main moved to novox/mesh-catalog@4387e77f2899
mesh/check checking 443a340f06cc on 4387e77f2899
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-09 18:23:33 +01:00
jschoubben 15f81780b9 hand-acts: no entry skipped by an early-ended page, the answer counted as sent, completeness for the window asked (issue 1966 S8 review 1)
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-09 18:23:33 +01:00
jschoubben ea4dfaa5a2 mesh-hand-acts answers bounded, newest first; mesh-healers seeds from a partial answer (issue 1966 S8)
hand-acts read the whole log (84k entries, 47 MB) on every call and could never answer inside the call bound or
the bus payload. It now reads from the newest entry backwards, page by page, and stops at the window's start, at
900 KiB of acts or at 20 s, saying complete: false, cut and why. warrants is bounded the same way. mesh-healers'
start-up seed takes a partial answer and logs it, instead of retrying forever.

Quick fix: this (mesh-catalog only, about 1.5 h). Thorough fix: also the tool runner never freezing on an oversized
bundle line and logging unanswered calls (mesh-tools, a separate fix by another developer, lead's ruling Q3), and an
SDK-side bound. Chosen here: quick for this module, the thorough part running beside it; the byte cap is the
module's own (lead's ruling Q1, a `limit` argument is a later note).

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-09 18:23:33 +01:00
mesh-tools 7630e3cc9d Merge pull request 'Sessions merge without a human approval: the hard rule keeps a branch and a pull request for every change' (#641) from docs/no-merge-approval into main 2026-11-09 16:58:54 +00:00
jschoubben 994b25f540 Sessions merge without a human approval: the hard rule keeps a branch and a pull request for every change
mesh/delivery merged: source.main moved to novox/mesh-catalog@7630e3cc9d1e
mesh/check checking 994b25f540a1 on 7630e3cc9d1e
The operator, 2026-11-09: "Remove the instruction, i don't care about merges, and little about deliveries".

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 17:57:51 +01:00
mesh-tools 503411e628 Merge pull request 'mesh-build-go reads a compose ask's gzip-packed request (issue 1966, recovery; group with mesh-delivery)' (#640) from fix/1966-checker-compositions-objects into main 2026-11-09 16:31:22 +00:00
jschoubben 4e2a1a5a1f mesh-build-go reads a compose ask's gzip-packed request (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@503411e6289b
mesh/delivery-group group fix/1966-checker-compositions-objects merged: every member is merged
mesh/check checking 4e2a1a5a1f58 on 503411e6289b | novox/mesh-catalog#640 in group fix/1966-checker-compositions-objects
The checker sends a large node's request packed, as it passes the bus's message limit; compose reads either form through
ComposeJob.RequestBytes. Only mesh-build-go serves compose. Delivered as a group with the mesh-delivery change of the same
branch name: an older mesh-build-go refuses a packed ask naming that it holds no request object.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-09 13:36:38 +01:00
mesh-tools 6a24e4d0c7 Merge pull request 'Gate record of mesh-catalog's forge-made merges (issue 1966, recovery)' (#639) from fix/1966-forge-made-merge into main 2026-11-09 08:45:50 +00:00
jschoubben 80186d0798 Record this repository's forge-made merges in the delivery gate, so the indexer takes them in (issue 1966, recovery)
mesh/delivery merged: source.main moved to novox/mesh-catalog@6a24e4d0c77e
mesh/delivery-group group fix/1966-forge-made-merge merged: every member is merged
mesh/check checking 80186d079808 on 6a24e4d0c77e | novox/mesh-catalog#639 in group fix/1966-forge-made-merge
Merges made on the forge directly since 2026-11-09 07:04 UTC have no gate merge record,
and the indexer holds them; an empty merge through the gate covers this repository's head.
Second of its kind: the group's mesh-delivery member removes the need (the forge bridge
reads a forge-made merge's pull request from the forge).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 09:17:03 +01:00
mesh-tools 89cd8a2e8e Merge pull request 'The provisioner keeps a provision while a module's newest composition is unread, and withdraws an ask once a key is needed again (issue 1966, recovery)' (#638) from fix/1966-provisioner-newest-held into main 2026-11-09 08:07:53 +00:00
jschoubben 4658e1cb1c A newest hold's key is <node>.<module>=newest, which the bus holds, and the test store refuses what the bus refuses (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@89cd8a2e8ed2
mesh/check checking 4658e1cb1c6c on 6a24e4d0c77e
Review round 2: '@' is outside the bus's key rule, so every newest hold failed to write
on the live bus while the in-memory store took it.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 09:06:39 +01:00
jschoubben 6e6b325cf1 Newest holds are kept on the bus in refused, and every key needed again withdraws its retire ask by one helper (issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 09:02:40 +01:00
jschoubben bbe107fa2a A provision only the newest build requires is kept while that build's composition is unread, and a key needed again withdraws its retire ask (issue 1966)
Not read is not unneeded on the newest scope too (issue 940's rule): a module whose newest composition is
unpublished or refused is held at its newest build, said in provisioner_plans newest-held and bounded on the health.
A retiring key needed again cancels its ask on operator-channel (accept.cancel), retried until the router takes it.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 08:52:16 +01:00
mesh-tools ac94f8338f Merge pull request 'distribution's program listens on 5100 and the registry container on 5001, so no mapping names the artifact store's port (issue 1966, recovery)' (#637) from fix/1966-store-listen into main 2026-11-09 00:58:10 +00:00
jschoubben a0459a8553 distribution's manifest test reads the listens with its own struct and checks every mapping's sides for 5100
mesh/delivery merged: source.main moved to novox/mesh-catalog@ac94f8338f9c
mesh/check checking a0459a855311 on ac94f8338f9c
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 01:42:40 +01:00
jschoubben 6cf7f67a1d distribution's program listens on 5100 and the registry container on 5001, so no mapping names the store's port
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 6cf7f67a1dfd on 08fbc51af26f
The composer finds a listen's machine port in a container mapping that names
it on either side (MachineSide). The program's listen 5000 matched the
registry container's inner 5000 in 127.0.0.1:5001:5000, so the store's address
became the container's loopback side and the program's own port was never
opened: the move to 83cd1a3d on 2026-11-08 23:25 UTC cut the artifact store off
(issue 1966 note 109). The program now binds 5100, where every machine fetches
(the artifact-store alias and the seats' reach agree), and the container serves
5001 inside and out. A row holds that no container names 5100.

Undo: revert this commit.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 01:40:49 +01:00
mesh-tools 08fbc51af2 Merge pull request 'The keeper's health reads the consumers it looks for (issue 1966, recovery)' (#636) from fix/keeper-health-consumers into main 2026-11-08 22:55:44 +00:00
jschoubben 1ef1b13560 The keeper's health reads the consumers it looks for: the shared monitoring read asks for them
mesh/delivery merged: source.main moved to novox/mesh-catalog@08fbc51af26f
mesh/check checking 1ef1b1356044 on 08fbc51af26f
nats_keeper_health found the keeper's consumer novox_nats_keeper in /jsz, but the read it shares with the
broker verbs asked /jsz?accounts=1&streams=1&config=1, without consumers=1, so keeperPending always read -1
and the keeper was "unhealthy: what waits on its consumer cannot be read from the bus's monitoring" for
ever (2026-11-08, issue 1966).

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 23:55:32 +01:00
mesh-tools 7fd4ed5898 Merge pull request 'The bus keeper reads a bare stream's max-age-days "none" as kept for good (issue 1966, recovery)' (#635) from fix/keeper-age-none into main 2026-11-08 22:53:04 +00:00
jschoubben bc8cb3b153 The bus keeper reads a bare stream's max-age-days "none" as kept for good, as the judge accepts it
mesh/delivery merged: source.main moved to novox/mesh-catalog@7fd4ed589806
mesh/check checking bc8cb3b153cf on 7fd4ed589806
mesh-knowledge's stream reviews says max-age-days "none", which the indexer's judge accepts
(judge/busstate.go noAge), but the keeper refused it ("not a whole number"), so the stream was
never made and the keeper stayed unhealthy (2026-11-08, issue 1966). Quick fix; the thorough one,
one shared reader of the template options in mesh-sdk catalogue for the judge and the keeper, is
noted on issue 1966.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 23:52:51 +01:00
mesh-tools 9480557537 Merge pull request 'dnsmasq: the public upstream resolvers are settings, not fixed in the template' (#634) from fix/dnsmasq-upstreams into main 2026-11-08 22:51:12 +00:00
jschoubben c88be88ccf dnsmasq's public upstream resolvers are config values, defaulting to the two it always used
mesh/delivery merged: source.main moved to novox/mesh-catalog@94805575379f
mesh/check checking c88be88ccf2f on 94805575379f
The two server lines were literals in the template, so changing an upstream needed a build. They are now the config keys upstream-resolver-first (1.1.1.1) and upstream-resolver-second (8.8.8.8), placed with ${config:...}; the service already restarts on its config file. no-resolv and every other line stay.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 23:48:59 +01:00
mesh-tools 53d173aa7d Merge pull request 'Forge database pool caps are config values, and a changed value recreates the forge (issue 1966, recovery)' (#633) from fix/1966-gitea-pool-config into main 2026-11-08 03:41:23 +00:00
jschoubben 4becc7bd83 Forge database pool caps are config values and a change recreates the forge (issue 1966, recovery)
mesh/delivery merged: source.main moved to novox/mesh-catalog@53d173aa7de5
mesh/check checking 4becc7bd8312 on 53d173aa7de5
The caps were literals in server.env and the container named no restart-on, so a changed env-file never reached the running forge. They are now three gitea config values placed with ${config:...}, and the server container restarts on server-env.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 03:33:16 +01:00
mesh-tools 61737962fc Merge pull request 'Cap the forge's database pool at 40: the store's 100 connections are shared (issue 1966, recovery)' (#632) from fix/1966-forge-pool-cap into main 2026-11-08 01:08:36 +00:00
jschoubben b4192c525e Cap the forge's database pool at 40 open connections: the store's 100 are shared by every module (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@61737962fcb6
mesh/check failed (3): ace: the check of novox/mesh-catalog#632 failed: downloading the asked version 68581…; and 2 more | b4192c525e7e on 61737962fcb6
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 02:01:20 +01:00
mesh-tools d03b455ea6 Merge pull request 'Manifests name their packages by full module path (issue 1966, bootstrap recovery)' (#631) from fix/1966-packages-by-module-path into main 2026-11-08 00:01:29 +00:00
jschoubben 1171b684d0 Round 1: distribution's manifest test expects the artifact-store alias the bootstrap keeps
mesh/delivery merged: source.main moved to novox/mesh-catalog@d03b455ea6e4
mesh/check checking 1171b684d08b on d03b455ea6e4
The review of 6547933b: the test still wanted the provision retired, though
80b8002a and 0c465fb9 keep it on port 5100 for the running node-engines; it
failed on main too (issue 1966).

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 01:00:40 +01:00
jschoubben 6547933b4d Name packages by their module path in 15 manifests (issue 1966)
Kind: short package names (builds, sshkeys, forgemsg, backupgood,
diskreadings, grantsstate, busgrants) in `packages`, which the indexer's
judge refuses by design 32's rule (a Go package is named by its go.mod's
module path). This held mesh-delivery-gate, the build modules, mounts and
restic. Each key is now the import path the module's code uses, with its
series kept; the manifest tests read the new keys, and the host/machine-name
word tests in mounts, nfs-server, mesh-build-node and mesh-delivery-gate
look past the path's `git.novox.be/novox/` prefix.

Quick fix: only the gate's manifest. Thorough fix (chosen): all 15, since
the same refusal holds each. Follow-up for the checker: the candidate
verdict lets such a name through (J7(3)), so the merge check did not stop it.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 00:38:55 +01:00
mesh-tools 0cd706b044 Merge pull request 'The gate part reads no pull request description: no review-line wait before the Approve (ADR 0659)' (#630) from fix/gate-reads-no-description into main 2026-11-07 22:55:00 +00:00
jschoubben 908f3918de A test over the gate's waits: no wait reads a description (ADR 0659 clause 6)
mesh/delivery merged: source.main moved to novox/mesh-catalog@0cd706b044ae
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-07 23:36:49 +01:00
jschoubben 8b84ac7487 Cite ADR 0659 where the gate reads no description; check-gates-merge words no longer wait for a review
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-07 23:33:05 +01:00
jschoubben 9698bcd934 The merge gate reads no pull request description: the review hold goes (reviewWaits, reviewWords, the Reviewed-by line)
The operator, 2026-11-07: remove any gate that reads a description. An unreviewed pull request whose
other waits are met is asked, and the ask names no review line.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-07 23:10:43 +01:00
jschoubben 21aa11aef1 Merge pull request 'The gate asks the Approve at once while the check gates no merge, never waiting for a verdict (issue 1966)' (#629) from fix/1966-no-check-wait into main
Reviewed-on: #629
2026-11-07 20:47:18 +00:00
jschoubben 145cf7e33a The gate asks the Approve at once while the check gates no merge, never waiting for a verdict (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@21aa11aef1af
mesh/check checking 145cf7e33afa on 21aa11aef1af
With check-gates-merge false (the configuration seat's value) the gate still held the Approve until the check had
a verdict (verdictWaits), so a one-line recovery waited half an hour for a whole-suite check it does not gate. The
operator, 2026-11-07: 'we aren't running any checks anymore, we're delivering'; 'disable the check'. The Approve is
now asked once the delivery is reviewed and ready; a later verdict is still shown and asks the pending Approve again
in its words. Tests follow: asked at once with no verdict or a running check, a group too, a re-pushed member too.

Merged by the session as a recovery fix (instruction recovery-fixes-merge-and-tell), the operator told on Telegram.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 21:46:34 +01:00
1049 changed files with 49262 additions and 40096 deletions
+4 -4
View File
@@ -43,7 +43,10 @@ dist/
/modules/claude-code/claude-code
/modules/claude-code/cmd/claude-code/claude-code
/modules/claude-licence-manager/claude-licence-manager
/modules/claude-licence-manager/cmd/claude-licence-manager/claude-licence-manager
/modules/claude-licence-manager/cmd/manager/manager
/modules/claude-licence-manager/cmd/tools/tools
/modules/claude-licence-manager/manager
/modules/claude-licence-manager/tools
/modules/clipmenu/clipmenu-tools
/modules/clipmenu/cmd/clipmenu-tools/clipmenu-tools
/modules/cups/cmd/cups-tools/cups-tools
@@ -112,9 +115,6 @@ dist/
/modules/mailu/cmd/automx/automx
/modules/memory-pressure/cmd/memory-pressure/memory-pressure
/modules/memory-pressure/memory-pressure
/modules/mesh-build-go/buildstep/cmd/mesh-pack/mesh-pack
/modules/mesh-build-go/buildstep/cmd/seatgen/seatgen
/modules/mesh-build-go/buildstep/mesh-pack
/modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
/modules/mesh-build-go/mesh-build-go
/modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
+27 -3
View File
@@ -4,7 +4,8 @@
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
// exempt from that, and said so. Every build module, test step or not, must also hold a test that calls
// conformance.LeftRunning with its own start sweep, so a step a previous process left running is ended at its start
// (issue 1890).
// (issue 1890), and one that calls conformance.Passing, so a step whose network did not answer is said as a transport fault
// (issue 1748). Its manifest asks the capability seat-settle-delay (ADR 0664 §5).
package failurekit
import (
@@ -13,6 +14,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strings"
)
@@ -27,9 +29,15 @@ type manifest struct {
Claims []struct {
Name string `json:"name"`
} `json:"claims"`
Tools []json.RawMessage `json:"tools"`
Tools []json.RawMessage `json:"tools"`
Capabilities []string `json:"capabilities"`
}
// settleDelay is the capability a tool runner states when it can settle a seat's ask with a delayed negative acknowledgement
// (mesh-sdk CapSeatSettleDelay): a build module gives its ask again after a delay by it, so the delivery plan places a tool
// runner that states it before the module (ADR 0664 §1, §5).
const settleDelay = "seat-settle-delay"
// Judge reads every modules/<m>/module.json of the catalogue at root and judges each module that claims a node-build-*
// seat.
func Judge(root string) ([]Judged, error) {
@@ -83,6 +91,22 @@ func Judge(root string) ([]Judged, error) {
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
"(mesh-build-go buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
}
// Every build module meets the network (a fetch, a download, an install, a publish), so every one runs the passing case: its
// steps say a network that did not answer as a transport fault the bus gives the ask again for (ADR 0664, issue 1748).
says, err := runsTheKit(dir, "conformance.Passing(")
if err != nil {
return nil, err
}
if !says {
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.Passing "+
"(mesh-build-go buildstep/conformance): run the kit's passing case with the module's own path from a failed run to its step's error (issue 1748)", seat))
}
// Every build module gives a step that met a transport fault again by the bus (ADR 0664 §5): it asks the tool runner's
// capability in its manifest, so the delivery plan places a tool runner that states it first.
if !slices.Contains(m.Capabilities, settleDelay) {
refused = append(refused, fmt.Sprintf("it claims %s, and its manifest's capabilities do not ask %s: a step given again by the bus needs "+
"a tool runner that can settle with a delay (ADR 0664 §5, issue 1748)", seat, settleDelay))
}
j.Refused = strings.Join(refused, "; ")
out = append(out, j)
}
@@ -121,7 +145,7 @@ func runsTheKit(dir, call string) (bool, error) {
return err
}
found = strings.Contains(string(raw), call) &&
strings.Contains(string(raw), `"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance"`)
strings.Contains(string(raw), `"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance"`)
return nil
})
return found, err
+12 -7
View File
@@ -8,23 +8,28 @@ import (
// 1901/AC7 and 1890/AC8: a build module with a test step and no kit is refused, one that runs the kit passes, one with no
// test step is exempt from the failure cases but not from the left-running case, a module that runs only the failure
// cases is refused, and a module that claims no node-build-* seat is not judged.
// cases is refused, a module that does not ask the capability seat-settle-delay is refused (ADR 0664 §5), and a module that
// claims no node-build-* seat is not judged.
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
root := t.TempDir()
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n"
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n"
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance\"\n\n"
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\nfunc TestPassing(t *testing.T) { conformance.Passing(t, nil, nil) }\n"
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
for path, body := range map[string]string{
"modules/a/module.json": `{"claims":[{"name":"node-build-a"}],"tools":["build_a_fetch","build_a_test"]}`,
"modules/a/cmd/a/check_test.go": "package main\n",
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"tools":[{"name":"build_b_test"}]}`,
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"capabilities":["seat-settle-delay"],"tools":[{"name":"build_b_test"}]}`,
"modules/b/cmd/b/kit_test.go": kit,
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"tools":["build_c_build"]}`,
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"capabilities":["seat-settle-delay"],"tools":["build_c_build"]}`,
"modules/c/cmd/c/kit_test.go": imp + sweep,
"modules/f/module.json": `{"claims":[{"name":"node-build-f"}],"tools":["build_f_build"]}`,
"modules/g/module.json": `{"claims":[{"name":"node-build-g"}],"tools":["build_g_test"]}`,
"modules/g/cmd/g/kit_test.go": imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n",
"modules/d/module.json": `{"claims":[{"name":"other"}],"tools":["d_test"]}`,
"modules/h/module.json": `{"claims":[{"name":"node-build-h"}],"tools":["build_h_build"]}`, // the sweep, and no passing case
"modules/h/cmd/h/kit_test.go": imp + "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n",
"modules/i/module.json": `{"claims":[{"name":"node-build-i"}],"capabilities":["other"],"tools":["build_i_build"]}`, // the whole kit, and no seat-settle-delay
"modules/i/cmd/i/kit_test.go": imp + sweep,
"modules/e/module.json": `{"claims":[{"name":"node-build-e"}],"tools":["build_e_test"]}`,
"modules/e/cmd/e/kit.go": kit, // not a test file
} {
@@ -40,9 +45,9 @@ func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused"}
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused", "h": "refused", "i": "refused"}
if len(got) != len(want) {
t.Fatalf("judged %+v, want a, b, c, e, f and g", got)
t.Fatalf("judged %+v, want a, b, c, e, f, g, h and i", got)
}
for _, j := range got {
is := "passes"
+7 -2
View File
@@ -5,7 +5,8 @@
// go run . <catalogue root>
//
// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go
// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages.
// ownDependencies): the `dependencies` beyond the mesh's own packages (@novox/mesh-*, the SDK among them), typescript and the
// @types packages.
package main
import (
@@ -17,6 +18,10 @@ import (
"strings"
)
// meshPackages is the prefix of every package the mesh itself publishes: the build module's toolchain and the mesh's registry
// give them, as they give the SDK, so a module naming only them needs no lock (design 47 G A-56).
const meshPackages = "@novox/mesh-"
// Finding is one module refused: it names Packages beyond the SDK and has no lock file.
type Finding struct {
Module string
@@ -70,7 +75,7 @@ func Check(root string) ([]Finding, error) {
}
var names []string
for n := range pkg.Dependencies {
if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") {
if !strings.HasPrefix(n, meshPackages) && n != "typescript" && !strings.HasPrefix(n, "@types/") {
names = append(names, n)
}
}
+2
View File
@@ -35,6 +35,8 @@ func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) {
root := t.TempDir()
module(t, root, "plain", "typescript", sdkOnly, false)
module(t, root, "none", "typescript", "", false)
// The mesh's own packages count as the SDK does (design 47 G A-56): a contracts package needs no lock.
module(t, root, "contracts", "typescript", `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","@novox/mesh-delivery-contracts-git":"^0.1.0"}}`, false)
got, err := Check(root)
if err != nil || len(got) != 0 {
t.Fatalf("%v %v", got, err)
+1 -1
View File
@@ -11,4 +11,4 @@ replace git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings => ../../
replace git.novox.be/novox/mesh-catalog/modules/restic/backupgood => ../../modules/restic/backupgood
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
+44 -23
View File
@@ -31,6 +31,7 @@ import (
"git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings"
"git.novox.be/novox/mesh-catalog/modules/restic/backupgood"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Known are the constants of the owners' published packages an announcement may name a state by, by the selector the
@@ -53,6 +54,7 @@ func Findings(root string) ([]string, error) {
}
dirs := map[string]string{}
reads := map[string][]string{}
holders := map[string][]string{} // seat -> the modules of the catalogue that claim it
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
@@ -61,11 +63,17 @@ func Findings(root string) ([]string, error) {
var m struct {
Module string `json:"module"`
Reads []json.RawMessage `json:"reads"`
Claims []struct {
Name string `json:"name"`
} `json:"claims"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
dirs[m.Module] = filepath.Dir(path)
for _, c := range m.Claims {
holders[c.Name] = append(holders[c.Name], m.Module)
}
for _, r := range m.Reads {
var s string
if json.Unmarshal(r, &s) == nil {
@@ -77,32 +85,45 @@ func Findings(root string) ([]string, error) {
owners := map[string]Announced{}
for reader, rs := range reads {
for _, r := range rs {
owner, state, ok := strings.Cut(r, ".")
dir, inCatalogue := dirs[owner]
if !ok || !inCatalogue || owner == reader {
continue
}
a, seen := owners[owner]
if !seen {
var problems []string
a, problems, err = AnnouncedBy(dir)
// A read in the seat form `seat:<seat>.<state>` (design 47 G A-116, A-128) is of the holder of the seat, found where it
// is read: each module of the catalogue claiming the seat is judged for the state, as a read of it by name is.
var targets []string
state := ""
if seat, local, isSeat, err := catalogue.SeatRead(r); isSeat {
if err != nil {
return nil, err
continue // the manifest validator names a malformed seat read
}
owners[owner] = a
for _, p := range problems {
out = append(out, owner+": "+p)
targets, state = holders[seat], local
} else if owner, local, ok := strings.Cut(r, "."); ok {
targets, state = []string{owner}, local
}
for _, owner := range targets {
dir, inCatalogue := dirs[owner]
if !inCatalogue || owner == reader {
continue
}
a, seen := owners[owner]
if !seen {
var problems []string
a, problems, err = AnnouncedBy(dir)
if err != nil {
return nil, err
}
owners[owner] = a
for _, p := range problems {
out = append(out, owner+": "+p)
}
}
if a == nil {
continue // announces nothing from code: read as before
}
readable, keeps := a[state]
switch {
case !keeps:
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner))
case !readable:
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner))
}
}
if a == nil {
continue // announces nothing from code: read as before
}
readable, keeps := a[state]
switch {
case !keeps:
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner))
case !readable:
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner))
}
}
}
+9
View File
@@ -50,6 +50,15 @@ func TestFindings(t *testing.T) {
"modules/r/module.json": `{"module":"r","reads":["o.bound"]}`,
"modules/o/module.json": `{"module":"o"}`,
"modules/o/index.ts": `await keeps([{ name: "bound", readable: true }]);`}, ""},
{"seat form, the holder's state not readable", map[string]string{
"modules/r/module.json": `{"module":"r","reads":["seat:the-seat.s"]}`,
"modules/o/module.json": `{"module":"o","claims":[{"name":"the-seat","scope":"mesh"}]}`,
"modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"s\"}\n"},
"r reads o.s, which o's code announces not readable"},
{"seat form, the holder's state readable, and a seat no module of the catalogue holds", map[string]string{
"modules/r/module.json": `{"module":"r","reads":["seat:the-seat.s","seat:elsewhere.s"]}`,
"modules/o/module.json": `{"module":"o","claims":[{"name":"the-seat","scope":"mesh"}]}`,
"modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"s\", Readable: true}\n"}, ""},
{"an owner that announces nothing, and one outside the catalogue", map[string]string{
"modules/r/module.json": `{"module":"r","reads":["o.s","mesh-controller.lease"]}`,
"modules/o/module.json": `{"module":"o"}`,
+6 -6
View File
@@ -1,6 +1,6 @@
// Command removedpackages refuses a Go source of the catalogue that imports what mesh-sdk gave up in the release that
// narrowed it (novox/hq ADR 0593 §1): a package that left for its owner, and the build runner's names that left
// mesh-sdk's buildstep for mesh-build-go's package.
// mesh-sdk's buildstep for the build runner's package (now the delivery SDK's steprun).
//
// A module compiles against the mesh-sdk version it pins, so a source that still names a removed package builds until the
// pin moves, and then breaks in a delivery of its own; and a later change could bring the import back with the old pin.
@@ -8,8 +8,8 @@
//
// - an import of a package in Removed (or one under it);
// - in a file importing mesh-sdk's buildstep (under any name), a selector naming anything that is not in
// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is
// mesh-build-go's package (git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep).
// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is the
// delivery SDK's package steprun (git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun).
//
// It reads the non-test and test Go files under modules/ and checks/ (testdata, vendor and node_modules left out).
//
@@ -35,8 +35,8 @@ const sdk = "git.novox.be/novox/mesh-sdk/go"
// Removed are the packages of mesh-sdk that left it, each with where its logic is now.
var Removed = map[string]string{
"buildcpu": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu",
"buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance",
"buildcpu": "the build runner's package, git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/buildcpu",
"buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance",
"delivery/buildrun": "the builder's package (mesh-delivery-builder), imported by its repository's modules",
"compose": "the node-engine, which alone composes (ADR 0593 §6)",
"compose/frombus": "the node-engine, which alone composes (ADR 0593 §6)",
@@ -140,7 +140,7 @@ func fileFindings(path, rel string) ([]string, error) {
return true
}
seen[sel.Sel.Name] = true
out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name))
out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name))
return true
})
return out, nil
+2 -2
View File
@@ -18,11 +18,11 @@ func TestFindingsRefuseWhatMeshSDKGaveUp(t *testing.T) {
{"clean", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/delivery\"\n)\n\nvar _ = delivery.StepsState\n", ""},
{"a removed package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n\nvar _ = compose.Resolve\n", "imports git.novox.be/novox/mesh-sdk/go/compose, which left mesh-sdk; it is the node-engine"},
{"under a removed package", "modules/a/x_test.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose/frombus\"\n)\n", "the node-engine"},
{"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "buildstep/buildcpu"},
{"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "steprun/buildcpu"},
{"the runner through mesh-sdk's buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", "uses buildstep.RunAsk, which is the build runner's"},
{"the runner through an alias", "modules/a/x.go", head + "\tsdkstep \"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = sdkstep.Puller{}\n", "uses sdkstep.Puller"},
{"the contract of buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.NewID\nvar _ buildstep.Failure\n", ""},
{"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", ""},
{"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun\"\n)\n\nvar _ = steprun.RunAsk\n", ""},
{"a copy in testdata", "modules/a/testdata/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n", ""},
} {
t.Run(row.name, func(t *testing.T) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "adwaita",
"version": "1",
"version": "0.1",
"moved": true,
"capabilities": [
"package-manager"
@@ -5,7 +5,7 @@ import (
"fmt"
"sort"
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep"
)
// The holders whose states this module reads, one constant each (a reader names its holder once; ADR 0634 §4).
@@ -20,21 +20,21 @@ import (
"fmt"
engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -45,8 +45,6 @@ const (
HavesRead = "node-engine.have"
// ManifestsRead is the indexer's manifests: the images a manifest pins by itself.
ManifestsRead = indexerModule + "." + catalog.StateManifests
// FindingsState is the holder's own findings (meshconditions.StateFindings): image-missing.
FindingsState = meshconditions.StateFindings
// Module is this module, the observer its findings name.
Module = "artifact-store-tools"
)
@@ -83,8 +81,10 @@ type Door interface {
// Holder is the seat's holder.
type Holder struct {
Bus Bus
Door func() (Door, error)
Bus Bus
// Findings is the module's finding writer: S25's and the sweep's findings go through it.
Findings Findings
Door func() (Door, error)
// Listed is the store as its seats list it now (Store.View); nil reads none, and then nothing is said gone or collected.
Listed func(ctx context.Context) (*View, error)
Acts meshhandactsclient.Recorder
@@ -95,6 +95,7 @@ type Holder struct {
sweep sync.Mutex // one sweep at a time
mu sync.Mutex
fmu sync.Mutex // one pass of Observe at a time
ended map[string]time.Time // a done key's end, read once from its history
}
@@ -852,16 +853,60 @@ func inStoreAt(image, address string) string {
return r
}
// Observe writes S25's findings (image-missing): each want whose build names an image the store does not hold, on the
// want's machine, and deletes each finding that no longer holds. Nothing is said without a listing.
// Observe says S25's findings (image-missing): each want whose build names an image the store does not hold, on the
// want's machine, written once and cleared once it no longer holds. Nothing is said without a listing. What stands is what
// the held writer holds (design 47 G A-32, A-38), so a restart neither drops nor rewrites it.
func (h *Holder) Observe(ctx context.Context) error {
j, err := h.judge(ctx)
now, err := h.observed(ctx)
if err != nil {
return err
}
if j.view == nil {
if now == nil {
return nil
}
h.fmu.Lock()
defer h.fmu.Unlock()
said := map[string]meshconditions.Finding{}
for _, f := range h.Findings.Held() {
if f.Kind == ImageMissing {
said[views.FindingKey(f.Scope, f.ID, f.Kind)] = f
}
}
for k, was := range said {
if _, still := now[k]; !still {
if err := h.Findings.Clear(ctx, was.Scope, was.ID, was.Kind, "the store holds the image again"); err != nil {
return err
}
}
}
for k, f := range now {
if was, ok := said[k]; ok && was.Summary == f.Summary {
continue // written only when what it says changes
}
if err := h.Findings.Write(ctx, f); err != nil {
return err
}
}
return nil
}
// Findings is the module's held finding writer (views.HeldWriter): the holder writes and clears through it and reads what it
// holds (design 47 G §689, A-29, A-38).
type Findings interface {
Write(ctx context.Context, finding meshconditions.Finding) error
Clear(ctx context.Context, scope, id, kind, why string) error
Held() []meshconditions.Finding
}
// observed is S25's findings now by key, nil without a listing.
func (h *Holder) observed(ctx context.Context) (map[string]meshconditions.Finding, error) {
j, err := h.judge(ctx)
if err != nil {
return nil, err
}
if j.view == nil {
return nil, nil
}
address := ""
if d, err := h.Door(); err == nil {
address = d.Address()
@@ -870,7 +915,7 @@ func (h *Holder) Observe(ctx context.Context) error {
for _, b := range j.built {
builds[b.Module+" "+b.FP] = b
}
now := map[string]meshconditions.FindingsByKeyValue{}
now := map[string]meshconditions.Finding{}
for _, w := range j.wants {
b, ok := builds[w.Module+" "+w.FP]
if !ok {
@@ -895,63 +940,26 @@ func (h *Holder) Observe(ctx context.Context) error {
continue
}
f := imageMissing(w.Node, w.Module, reference, h.now())
now[f.Key] = f
now[views.FindingKey(f.Scope, f.ID, f.Kind)] = f
break
}
}
keys, err := h.Bus.Keys(FindingsState)
if err != nil {
return fmt.Errorf("the findings: %w", err)
}
for _, k := range keys {
if k == sweepFindingKey {
continue // the sweep's own finding, which the sweep clears
}
if _, still := now[k]; !still {
// A finding of a first view unanswered is the reader's (views.Reader), cleared by its answer, not by a sweep.
if raw, found, err := h.Bus.Get(FindingsState, k); err == nil && found {
if f, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && f.Kind == views.UnansweredKind {
continue
}
}
if err := h.Bus.Delete(FindingsState, k); err != nil {
return err
}
}
}
for k, f := range now {
raw, found, err := h.Bus.Get(FindingsState, k)
if err != nil {
return err
}
if found {
if was, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && was.Summary == f.Summary {
continue // written only when what it says changes
}
}
if err := meshconditionsclient.CheckFinding(f); err != nil {
return err
}
if err := h.Bus.Put(FindingsState, k, f); err != nil {
return err
}
}
return nil
return now, nil
}
// imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages).
func imageMissing(node, module, image string, at time.Time) meshconditions.FindingsByKeyValue {
id := module + "." + node
return meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: meshconditionsclient.Key(meshconditionsclient.ScopeModule, id, ImageMissing), Kind: ImageMissing,
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: id, Machine: node},
Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator,
// imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages); its id
// is the module's and the machine's, each escaped (design 47 G A-14).
func imageMissing(node, module, image string, at time.Time) meshconditions.Finding {
id := keys.EscapeName(module) + "." + keys.EscapeName(node)
return meshconditions.Finding{Scope: "module", ID: id, Kind: ImageMissing,
Subject: meshconditions.Subject{Scope: "module", ID: id, Machine: node},
Severity: meshconditions.FindingSeverityWarning, Resolver: "operator",
Summary: fmt.Sprintf("%s is left out of every send to %s: its declaration names an image that the artifact store does "+
"not hold (the evidence names it); the running container is untouched", module, node),
Headline: capital(module) + " is not updated on " + node,
Explanation: fmt.Sprintf("The new version of %s names a container image that the mesh's store does not hold, so it "+
"was not sent to %s. What runs there is untouched, and the rest of %s is sent as usual.", module, node, node),
Needs: fmt.Sprintf("build %s again; the details name the image.", module),
Resolved: "Resolved: the store holds the image of " + module + " again",
Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}},
Source: "S25", Observer: Module, At: at.UTC()}
}
@@ -198,7 +198,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) {
}
return m.Data, nil
}
h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf}
h := &Holder{Bus: findingsAt{realBus: bus}, Findings: findingsThrough{findingsAt{realBus: bus}}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf}
h.Acts = meshhandactsclient.Recorder{Ask: ask}
// A build's end heard from the builds bucket, as follow hands it over.
@@ -6,10 +6,10 @@ import (
"errors"
"fmt"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"slices"
"sort"
"strings"
@@ -17,14 +17,14 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry"
)
// memBus is the holder's bus in memory: states by name, each key's history, and what was published.
@@ -229,7 +229,7 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) {
want, _ := json.Marshal(meshwants.WantsByWantNodeModuleValue{V: 1, Fp: fp(1), Self: true})
_ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want))
var acts []string
h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, Seats: door.seat,
h := &Holder{Bus: b, Findings: findingsThrough{b}, Door: func() (Door, error) { return door, nil }, Seats: door.seat,
Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil },
Now: func() time.Time { return day.Add(48 * time.Hour) },
Acts: meshhandactsclient.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
@@ -370,7 +370,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
if c.letGo[0] == archiveRef("letta", 2) && !slices.Contains(door.calls, "seat:oci-artifact-registry.remove letta/files@"+digestOf(archiveRef("letta", 2))+" "+Retention) {
t.Errorf("the seats were called %v", door.calls)
}
_, finding, _ := b.Get(FindingsState, sweepFindingKey)
_, finding, _ := b.Get(FindingsState, views.FindingKey("module", Module, SweepRefused))
if finding != (c.refuse != "") {
t.Errorf("the sweep's finding is written: %v, want %v", finding, c.refuse != "")
}
@@ -379,7 +379,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
if _, err := h.Sweep(context.Background(), Sweeping{Most: MostPerSweep, By: "again"}); err != nil {
t.Fatal(err)
}
if _, still, _ := b.Get(FindingsState, sweepFindingKey); still {
if _, still, _ := b.Get(FindingsState, views.FindingKey("module", Module, SweepRefused)); still {
t.Error("a clean sweep left the sweep's finding")
}
return
@@ -410,13 +410,16 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
if err := h.Observe(context.Background()); err != nil {
t.Fatal(err)
}
key := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "letta.ace", ImageMissing)
key := views.FindingKey("module", "letta.ace", ImageMissing)
raw, found, _ := b.Get(FindingsState, key)
f, err := meshconditions.DecodeFindingsByKeyValue(raw)
var f meshconditions.Finding
err := json.Unmarshal(raw, &f)
if !found || err != nil || f.Subject.Machine != "ace" || f.Source != "S25" || !strings.Contains(f.Evidence[0].Said, imageRef("letta", 1)) {
t.Fatalf("the finding is %+v (%v, %v)", f, found, err)
}
view.L.Repos[repo].Revisions[digest] = true
// A restart: a holder made afresh holds no finding of its own, and clears what the writer holds (design 47 G A-32, A-38).
h = &Holder{Bus: h.Bus, Findings: h.Findings, Door: h.Door, Listed: h.Listed, Seats: h.Seats, Now: h.Now, Acts: h.Acts, Logf: h.Logf}
if err := h.Observe(context.Background()); err != nil {
t.Fatal(err)
}
@@ -441,3 +444,41 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
t.Fatalf("the images of ace are %+v: %v", images, err)
}
}
// FindingsState is the module's state `findings`, where the tests' writer keeps what the holder writes through it.
const FindingsState = "findings"
// findingsThrough is the holder's finding writer in a test: it keeps a finding in its key and deletes it as the module's
// writer does (views.Writer), without the bus.
type findingsThrough struct {
bus interface {
Put(state, key string, value any) error
Delete(state, key string) error
}
}
func (f findingsThrough) Write(_ context.Context, finding meshconditions.Finding) error {
return f.bus.Put(FindingsState, views.FindingKey(finding.Scope, finding.ID, finding.Kind), finding)
}
// Held is what the writer holds: the findings in the module's state.
func (f findingsThrough) Held() []meshconditions.Finding {
var out []meshconditions.Finding
keys, _ := f.bus.(interface {
Keys(string) ([]string, error)
}).Keys(FindingsState)
for _, k := range keys {
raw, ok, _ := f.bus.(interface {
Get(string, string) ([]byte, bool, error)
}).Get(FindingsState, k)
var m meshconditions.Finding
if ok && json.Unmarshal(raw, &m) == nil {
out = append(out, m)
}
}
return out
}
func (f findingsThrough) Clear(_ context.Context, scope, id, kind, _ string) error {
return f.bus.Delete(FindingsState, views.FindingKey(scope, id, kind))
}
@@ -11,9 +11,9 @@ import (
"fmt"
"sort"
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry"
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
)
// Repo is one repository as the seats' list answers it.
@@ -13,8 +13,8 @@ import (
"os"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -24,12 +24,29 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
func main() {
s := &Store{Ask: stdio.Ask}
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")}, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
v.Follow(assignerModule, "wants", WantsTool)
v.Follow(indexerModule, "manifests", ManifestsTool)
h := &Holder{Bus: runtimeBus{views: v}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE"), s), Listed: s.View,
Seats: stdio.Ask, Now: time.Now, Logf: logf}
h.Acts = meshhandactsclient.Recorder{Ask: stdio.Ask}
// The module's one held writer (design 47 G §689, A-16, A-29, A-32): the holder's findings and the reader's go through it.
w, err := views.NewStdioHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{
Asked: meshconditions.SubjectFindingsAsked, Module: Module, State: stdio.State("findings"), Kinds: []string{ImageMissing, SweepRefused}})
if err != nil {
logf("%v", err)
os.Exit(1)
}
h.Findings = w
v.Findings = w
// the runtime answers the writer's subscription only once Serve has its channel
go func() {
<-stdio.Served()
if err := w.Start(context.Background()); err != nil {
logf("%v", err)
os.Exit(1)
}
}()
v.Start(context.Background())
go follow(h, v)
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -1,8 +1,8 @@
package main
import (
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -474,14 +474,19 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
t.Errorf("invokes %v", m.Invokes)
}
// ADR 0615: the assigner's wants and the indexer's manifests are asked once and followed by their events, not read.
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests"}) ||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests", "mesh-conditions.findings-asked"}) ||
slices.Contains(m.Reads, WantsRead) || slices.Contains(m.Reads, ManifestsRead) {
t.Errorf("consumes %v, reads %v", m.Consumes, m.Reads)
}
// The operator, 2026-11-04 (issue 1966): it reaches the store through the registry seats' verbs, and requires no
// provision artifact-store.
if len(m.Requires) != 0 {
t.Errorf("requires %v, want nothing: the store is reached through the registry seats", m.Requires)
// What it requires is its finding state and stream alone, in their template form (design 47 G §529).
var required []string
for _, r := range m.Requires {
required = append(required, fmt.Sprint(r.(map[string]any)["name"]))
}
if !reflect.DeepEqual(required, []string{"findings", "finding-events"}) {
t.Errorf("requires %v, want its findings and finding-events alone: the store is reached through the registry seats", m.Requires)
}
// Its address file is filled from the image seat holder's listen (ADR 0632 §1), never by the retired provision.
address, err := os.ReadFile("../../files/store-address")
@@ -10,25 +10,23 @@ package main
// sweep's finding, sweep-refused; the next sweep with no refusal clears it.
import (
"context"
"encoding/json"
"fmt"
"strings"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/keys"
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
)
// SweepRefused is the sweep's finding: a seat refused a hold or a remove the sweep asked.
const SweepRefused = "sweep-refused"
// sweepFindingKey is the one key of the sweep's finding in the module's findings.
var sweepFindingKey = meshconditionsclient.Key(meshconditionsclient.ScopeModule, Module, SweepRefused)
// canonical is a kept reference in the older form, `artifact-store://<path>`, whichever form it came in; another is as
// it was.
func canonical(reference string) string {
@@ -115,26 +113,28 @@ func (h *Holder) removeOnSeat(reference, why string) error {
// sweepFinding writes the sweep's finding: what the seat refused, and why.
func (h *Holder) sweepFinding(what string, err error) {
at := h.now().UTC()
f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused,
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: Module},
Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator,
f := meshconditions.Finding{Scope: "module", ID: keys.EscapeName(Module), Kind: SweepRefused,
Subject: meshconditions.Subject{Scope: "module", ID: keys.EscapeName(Module)},
Severity: meshconditions.FindingSeverityWarning, Resolver: "operator",
Summary: fmt.Sprintf("the artifact sweep stopped: a registry seat refused %s (the evidence names why)", what),
Headline: "The artifact sweep stopped",
Explanation: "The sweep keeps what the mesh still needs and removes the rest through the registry seats. A seat " +
"refused one of its asks, so it stopped before anything more was removed; what it kept stays kept.",
Needs: "read the seat holder's journal for the refusal; the next sweep asks again.",
Resolved: "Resolved: the artifact sweep ran through without a refusal",
Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: fmt.Sprintf("%s: %v", what, err)}},
Source: "CAT-AS1", Observer: Module, At: at}
if werr := h.Bus.Put(FindingsState, sweepFindingKey, f); werr != nil {
if werr := h.Findings.Write(context.Background(), f); werr != nil {
h.logf("the sweep's finding (%s: %v) could not be written: %v", what, err, werr)
}
}
// sweepClean clears the sweep's finding after a sweep no seat refused.
// sweepClean clears the sweep's finding, when the held writer holds it, after a sweep no seat refused.
func (h *Holder) sweepClean() {
if _, found, err := h.Bus.Get(FindingsState, sweepFindingKey); err == nil && found {
if err := h.Bus.Delete(FindingsState, sweepFindingKey); err != nil {
for _, was := range h.Findings.Held() {
if was.Kind != SweepRefused {
continue
}
if err := h.Findings.Clear(context.Background(), was.Scope, was.ID, was.Kind, "a sweep ran through without a refusal"); err != nil {
h.logf("the sweep's finding could not be cleared: %v", err)
}
}
@@ -43,7 +43,7 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
t.Fatal(err)
}
// The builder's keys are read through its published package, taken by its series (ADR 0634 §1).
if m.Packages["builds"] != "0.1" {
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
}
for _, r := range m.Resources {
+10 -9
View File
@@ -5,18 +5,17 @@ go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
github.com/nats-io/nats.go v1.54.0
)
@@ -35,4 +34,6 @@ require (
golang.org/x/time v0.16.0 // indirect
)
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect
+26 -7
View File
@@ -1,8 +1,8 @@
{
"module": "artifact-store-tools",
"version": "1",
"version": "0.1",
"packages": {
"builds": "0.1"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
},
"claims": [
{
@@ -39,10 +39,8 @@
],
"consumes": [
"mesh-delivery-assigner.views.wants",
"mesh-delivery-indexer.views.manifests"
],
"state": [
"findings"
"mesh-delivery-indexer.views.manifests",
"mesh-conditions.findings-asked"
],
"tools": [
"artifact_store_repositories",
@@ -108,5 +106,26 @@
"from": "files"
}
]
}
},
"requires": [
{
"name": "findings",
"storage": "key-value",
"template": "state"
},
{
"name": "finding-events",
"storage": "stream",
"template": "events",
"options": {
"events": [
"finding-written.*",
"finding-cleared.*",
"findings-stated.*"
],
"max-age-days": 7,
"max-mib": 16
}
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "asus-zephyrus-g14",
"version": "1",
"version": "0.1",
"moved": true,
"templates": "templates",
"capabilities": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "audit-logger",
"version": "1",
"version": "0.1",
"slug": "audit",
"consumes": [
"**"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "avahi",
"version": "1",
"version": "0.1",
"capabilities": [
"package-manager",
"service-manager"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "baserow",
"version": "1",
"version": "0.1",
"capabilities": [
"container-runtime"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "blueman",
"version": "1",
"version": "0.1",
"capabilities": [
"package-manager"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "bluetooth",
"version": "1",
"version": "0.1",
"capabilities": [
"package-manager",
"service-manager"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "brother",
"version": "1",
"version": "0.1",
"requires": [
{
"name": "s3-bucket",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "buildah",
"version": "1",
"version": "0.1",
"capabilities": [
"package-manager",
"user-namespaces"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"module": "ca-trust",
"version": "1",
"version": "0.1",
"slug": "catrust",
"capabilities": [
"service-manager"
+7 -10
View File
@@ -78,12 +78,12 @@ must see, a node that joins later included — kept, so it carries no secret eit
| what | how |
|---|---|
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) |
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206), and said whole as the event `holdings-changed` at start, on every change and again when the licence manager says `holdings.asked` (design 39 bus-and-switch §23) |
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
| what this node should hold | the licence manager's `binding.changed` naming this node, and at start (or a reconnect) its first view, `anthropic-licence-manager.bindings` with `consumer` this node; an event for another node is ignored. On a newer generation, at start, and on the vault's `secret-changed` for the licence it holds, this module asks `anthropic-licence-manager.current` which licence, and the vault's `get` for that licence's access token (`licence.<key>.access`), sealed to its own key, and writes it access-token-only — so the agent here never refreshes, and never receives a refresh token. A home holding no token is fetched again at the same generation (design 39 bus-and-switch §19, §22) |
| 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 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 |
| 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) |
@@ -150,8 +150,8 @@ record is how a habit that found a way round is seen.
## Tools
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull`, `claude_code_grant` (for the licence
manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull` (asks the vault for this node's
licence token now), `claude_code_grant` (for the licence manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
more).
**What a session runs or obeys is the operator's, at the controller's terminal** (hq issue 340). A hook, a
@@ -233,11 +233,8 @@ key says waiting, chosen (the option, who answered, on which channel, when), end
refused, cancelled, replaced), or not proven: the answer is read from the router's own record and held to the
question as `questions` keeps it, so a question rewritten after it was asked, a warrant for another ask or a choice
after expiry is never read as a choice. One call waits at most 60 s; the tool asks a session to read a question at
most once a minute, and nothing enforces it. At most two questions wait at once, mesh-wide: each holds one of two
slot keys in `questions`, taken by create or compare-and-set, so two nodes asking at once cannot both take the last,
and a slot that cannot be read refuses the question. Questions alone never fill the router's three open asks, so one
is left for a proposed instruction section while two are open. A question the router holds no record of, its refusal
not heard here, keeps its slot and reads as waiting until it expires: the router may still take it from its queue,
most once a minute, and nothing enforces it. No question is refused for how many wait: the router offers an asker at most three asks at once, holds the oldest back behind newer ones and offers it again when one ends (hq ADR 0666 §12). A question the router holds no record of, its refusal
not heard here, reads as waiting until it expires: the router may still take it from its queue,
and no cancel can overtake it there (the queue offers work again after an error, and a cancel of an ask not yet
recorded is dropped); once expired, the router refuses it if taken late. A refused question keeps none of
its words. An answer is the operator's word to the session and authorises nothing; a question never defaults.
@@ -457,13 +457,9 @@ func TestTheGuardIsAskedAboutEveryToolAndRefusesEachForAnotherAccount(t *testing
func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) {
p, w := node(t, "anchor")
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
ask := func(address string, args any) (json.RawMessage, error) {
key := args.(map[string]any)["public_key"].(string)
g, _ := json.Marshal(Grant{AccessToken: "at", ExpiresAt: now + 3_600_000})
box, _ := Seal(string(g), key)
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 4, Sealed: &box,
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}})
}
var asked []string
ask := vaulted(t, Current{Licence: "personal", Key: "personal", Kind: "subscription", Generation: 4,
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}}, "at", &asked)
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil {
t.Fatal(err)
}
@@ -0,0 +1,18 @@
package main
// claude-code's reading of which modules exist and of each placed module's manifest (design 47 G §196, §594, §614): the
// catalog half, taken through meshcatalogclient's follower of the watched branches on the part's one views reader. A
// module's manifest is the one its catalog graph entry carries whole (§443), never the indexer's state `modules` or
// `manifests`.
import (
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
)
// CatalogHalf is what this module reads of the catalog half.
type CatalogHalf interface {
// Entry is a module's entry at the trunk; an error while a watched branch has no first view yet, never "no module".
Entry(module string) (catalog.CatalogEntry, bool, error)
// Ready is nil once every watched branch has its first view (none-yet counting as one), else says what it waits for.
Ready() error
}
@@ -0,0 +1,90 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-sdk/go/views"
)
// wiringOf is a part's reader in miniature: it keeps the followers adopted, so a test starts them as the reader's Start does.
type wiringOf struct {
mu sync.Mutex
fs []*views.Follower
}
func (w *wiringOf) Adopt(f *views.Follower, _, _ string, _ func(context.Context, []byte) error) error {
w.mu.Lock()
defer w.mu.Unlock()
w.fs = append(w.fs, f)
return nil
}
func (w *wiringOf) Drop(string, string) error { return nil }
// Design 47 G §196, §594: which modules exist, and the manifest of each, are the catalog half's; a branch with no first
// view yet is an error naming it, never no module, and an entry's manifest is the one it carries whole.
func TestCatalogHalfIsTheWatchedBranchesNewestRevision(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
answered := false
var mu sync.Mutex
call := func(key string, _ any) (json.RawMessage, error) {
mu.Lock()
defer mu.Unlock()
switch key {
case meshcatalogclient.RegistrationsVerb:
return json.Marshal(catalog.RegistrationsAnswer{Registrations: []catalog.Registration{{Module: "m", Version: 1, Branches: []string{"main"}}}})
case meshcatalogclient.SourceVerb:
return json.Marshal(delivery.SourceAnswer{})
case meshcatalogclient.RevisionVerb:
if !answered {
return nil, fmt.Errorf("the holder is away")
}
return json.Marshal(catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: 1, Kind: meshcatalogclient.KindTrunk},
Entries: []catalog.CatalogEntry{{Module: "pg", Manifest: map[string]any{"module": "pg"}}}})
}
return nil, fmt.Errorf("asked %s", key)
}
w := &wiringOf{}
b, err := meshcatalogclient.NewBranches(w, call)
if err != nil {
t.Fatal(err)
}
h := b
if _, _, err := h.Entry("pg"); err == nil || !strings.Contains(err.Error(), "no watched branch") {
t.Fatalf("before the registrations: %v, want an error naming the missing branch", err)
}
go func() { _ = w.fs[0].Start(ctx) }() // the registrations
for len(b.Followed()) == 0 {
select {
case <-ctx.Done():
t.Fatal("the registrations' branch is never followed")
case <-time.After(10 * time.Millisecond):
}
}
if _, _, err := h.Entry("pg"); err == nil || !strings.Contains(err.Error(), "no first view of main") {
t.Fatalf("with no first view: %v, want an error naming the branch, never no module", err)
}
mu.Lock()
answered = true
mu.Unlock()
c, _ := b.Catalog("main")
if err := c.Start(ctx); err != nil {
t.Fatal(err)
}
got, ok, err := h.Entry("pg")
if err != nil || !ok || got.Manifest["module"] != "pg" || h.Ready() != nil {
t.Fatalf("with the first view: %v, %v, %v", got, ok, err)
}
if _, ok, err := h.Entry("absent"); ok || err != nil {
t.Fatalf("a module the half does not list: %v, %v", ok, err)
}
}
@@ -1,13 +1,17 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/views"
)
var now = time.Now().UnixMilli()
@@ -282,31 +286,52 @@ func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
}
}
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
// seat answers `current` as the manager does, the licence and its key and never a token, and `get` as the vault does:
// the licence's access secret sealed to the key the node sent (design 39 bus-and-switch §18, §19).
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
return vaulted(t, Current{Licence: licence, Key: licence, Kind: "subscription", Generation: gen}, token, asked)
}
// vaulted answers `current` with c and the vault's `get` of c's access secret with token.
func vaulted(t *testing.T, c Current, token string, asked *[]string) Ask {
return func(address string, args any) (json.RawMessage, error) {
*asked = append(*asked, address)
key := args.(map[string]any)["public_key"].(string)
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
box, err := Seal(string(g), key)
if err != nil {
t.Fatal(err)
a := args.(map[string]any)
switch address {
case SeatVerb("current"):
if _, sent := a["public_key"]; sent || a["consumer"] == nil {
t.Fatalf("current asked with %v", a)
}
return json.Marshal(c)
case VaultGet:
if a["name"] != AccessSecret(c.Key) {
t.Fatalf("the vault asked for %v", a["name"])
}
// Each answer a little later, as each rotation hands a later token.
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000 + int64(len(*asked))*1000})
box, err := Seal(string(g), a["public_key"].(string))
if err != nil {
t.Fatal(err)
}
return json.Marshal(map[string]any{"name": a["name"], "generation": 1, "sealed": box})
}
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
t.Fatalf("asked %s", address)
return nil, nil
}
}
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
// design 39 bus-and-switch §19: a newer generation asks the seat which licence, and the vault for its token.
func TestANewerGenerationFetchesTheTokenOnceFromTheVault(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
ask := seat(t, "personal", "at-1", 3, &asked)
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
t.Fatal(err)
}
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
if len(asked) != 2 || asked[0] != SeatVerb("current") || asked[1] != VaultGet || creds(t, p)["accessToken"] != "at-1" {
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
}
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 2 {
t.Fatal("an equal generation asked again")
}
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
@@ -497,30 +522,104 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
}
}
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
// design 39 bus-and-switch §19, B11: only a newer generation than the one applied is fetched; a lower one is not, and
// the vault's secret-changed for the licence held asks its token again, for another secret nothing.
func TestTheTokenIsAskedOnANewerGenerationAndOnTheVaultsChange(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
t.Fatal(err)
}
// The rotation that came with it: a later token, as a refresh hands one over.
later := func(address string, args any) (json.RawMessage, error) {
asked = append(asked, address)
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
if err != nil {
t.Fatal(err)
}
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
if done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
seat(t, "personal", "at-low", 3, &asked), writer(w)); err != nil || done != "" || len(asked) != 2 {
t.Fatalf("a lower generation was fetched: %q %v, asked %v", done, err, asked)
}
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
later, writer(w)); err != nil {
rotated := seat(t, "personal", "at-new", 16, &asked)
for _, other := range []string{`{"name":"licence.personal.grant","generation":5}`, `{"name":"licence.other.access","generation":5}`} {
if done, err := OnSecretChanged(p, []byte(other), rotated, writer(w)); err != nil || done != "" || len(asked) != 2 {
t.Fatalf("%s asked again: %q %v, asked %v", other, done, err, asked)
}
}
if _, err := OnSecretChanged(p, []byte(`{"name":"licence.personal.access","generation":5,"by":"x","at":"2026-11-11T00:00:00Z"}`),
rotated, writer(w)); err != nil {
t.Fatal(err)
}
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
if len(asked) != 4 || asked[3] != VaultGet || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 16 {
t.Fatalf("asked %v, credentials %v: the vault's change was not applied", asked, creds(t, p))
}
}
// design 39 bus-and-switch §22/AC1, B22: binding.changed, decoded from a copy of the manager's form, is acted on only
// when it names this node's consumer; an event for another consumer is ignored.
func TestAnEventForAnotherConsumerIsIgnored(t *testing.T) {
p, w := node(t, "server")
var asked []string
ask := seat(t, "api", "at-3", 3, &asked)
said, err := os.ReadFile("testdata/binding-changed.json")
if err != nil {
t.Fatal(err)
}
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || done != "" || len(asked) != 0 {
t.Fatalf("an event for laptop was acted on by server: %q %v, asked %v", done, err, asked)
}
p, w = node(t, "laptop")
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":3`) || len(asked) != 2 {
t.Fatalf("laptop's own event: %q %v, asked %v", done, err, asked)
}
if done, _ := OnBindingChanged(p, []byte(`{"consumer":"laptop","licence":"","generation":3,"from":"api"}`), ask, writer(w)); !strings.Contains(done, "released") {
t.Fatalf("a release said %q", done)
}
}
// design 39 bus-and-switch §22, B10: at its start the module asks the seat's bindings for its own consumer and applies
// that first view; its manifest consumes binding.changed and no longer reads the manager's state.
func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
current := vaulted(t, Current{Licence: "api", Key: "api", Kind: "subscription", Generation: 9}, "at-9", &asked)
ask := func(address string, args any) (json.RawMessage, error) {
if address == SeatVerb("bindings") {
asked = append(asked, address)
if args.(map[string]any)["consumer"] != "laptop" {
t.Fatalf("bindings asked with %v", args)
}
return json.RawMessage(`[{"consumer":"laptop","licence":"api","key":"api","kind":"api-key","generation":9,"why":"carried over","by":"claude-licence-manager"}]`), nil
}
return current(address, args)
}
if done, err := FirstBinding(p, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":9`) {
t.Fatalf("the first view: %q %v, asked %v", done, err, asked)
}
if len(asked) != 3 || asked[0] != SeatVerb("bindings") || creds(t, p)["accessToken"] != "at-9" {
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
}
// §19: at every start the vault is asked, though the binding is the one applied.
if _, err := FirstBinding(p, ask, writer(w)); err != nil || len(asked) != 6 || asked[5] != VaultGet {
t.Fatalf("a start asked %v", asked)
}
// A-134: the first view is the follower the module's views reader adopts on binding.changed.<consumer>; its answer
// applies the binding as FirstBinding does.
r := &views.Reader{Module: Module}
f := bindingsFollower(p, ask, writer(w), func(done string, err error) {
if err != nil || !strings.Contains(done, `"generation":9`) {
t.Errorf("the adopted first view applied %q, %v", done, err)
}
})
if err := r.Adopt(f, Manager, BindingChangedEvent, func(context.Context, []byte) error { return nil }); err != nil || !r.Follows(Manager+".bindings") {
t.Fatalf("the bindings follower is not adopted: %v", err)
}
if err := f.Start(context.Background()); err != nil || len(asked) != 9 {
t.Fatalf("the adopted first view: %v, asked %v", err, asked)
}
raw, _ := os.ReadFile("../../module.json")
var m struct {
Consumes, Invokes []string
Reads []string
}
_ = json.Unmarshal(raw, &m)
if !slices.Contains(m.Consumes, BindingChanged) || !slices.Contains(m.Consumes, SecretChanged) ||
!slices.Contains(m.Invokes, SeatVerb("bindings")) || !slices.Contains(m.Invokes, VaultGet) || len(m.Reads) != 0 {
t.Fatalf("the manifest consumes %v, invokes %v, reads %v", m.Consumes, m.Invokes, m.Reads)
}
}
@@ -276,10 +276,12 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
}
_ = json.Unmarshal(raw, &m)
// The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed.
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) ||
!slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) ||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) {
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. The
// licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9); a
// notice goes out on the operator channel's notify (design 47 G GR8).
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", RevisionVerb, RegistrationsVerb, SourceVerb, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet, "seat:operator-channel.notify"}) ||
len(m.Reads) != 0 ||
!slices.Equal(m.Consumes, []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", HoldingsAsked, BindingChanged, SecretChanged}) {
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
}
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
@@ -0,0 +1,37 @@
package main
// What this node holds, said (design 39 bus-and-switch §23): kept in this module's own state `holdings` and said as
// the event `holdings-changed`, the holdings report whole and never a token, at start, whenever it changes, and again
// when the licence manager says `holdings.asked`.
import "encoding/json"
// HoldingsChanged is the event that says what a node holds.
const HoldingsChanged = "holdings-changed"
// HoldingsAsked is the licence manager's event asking every node to say what it holds.
const HoldingsAsked = Manager + ".holdings.asked"
// HoldingsSayer keeps and says what this node holds.
type HoldingsSayer struct {
Put func(Holdings) error
Emit func(Holdings) error
said string
}
// Say keeps and says now when it differs from what was said last, or always when again: true when it said it. A
// failure says nothing was said, so the next call tries again.
func (s *HoldingsSayer) Say(now Holdings, again bool) (bool, error) {
raw, _ := json.Marshal(now)
if !again && string(raw) == s.said {
return false, nil
}
if err := s.Put(now); err != nil {
return false, err
}
if err := s.Emit(now); err != nil {
return false, err
}
s.said = string(raw)
return true, nil
}
@@ -0,0 +1,52 @@
package main
import (
"encoding/json"
"errors"
"strings"
"testing"
)
// design 39 bus-and-switch §23: the holdings are said at start, on a change and when asked again, kept in the
// module's own state each time, the report whole and never a token.
func TestHoldingsAreSaidAtStartAndOnChange(t *testing.T) {
var kept, said []string
failing := false
s := &HoldingsSayer{
Put: func(h Holdings) error { raw, _ := json.Marshal(h); kept = append(kept, string(raw)); return nil },
Emit: func(h Holdings) error {
if failing {
return errors.New("no responders")
}
raw, _ := json.Marshal(h)
said = append(said, string(raw))
return nil
},
}
fp := "sha256:0123456789abcdef"
start := Holdings{Node: "laptop"}
changed := Holdings{Node: "laptop"}
changed.Refresh.Present, changed.Refresh.Fingerprint = true, &fp
for i, step := range []struct {
h Holdings
again, out bool
}{{start, false, true}, {start, false, false}, {changed, false, true}, {changed, true, true}} {
if got, err := s.Say(step.h, step.again); err != nil || got != step.out {
t.Fatalf("step %d: said %v (%v), want %v", i, got, err, step.out)
}
}
if len(said) != 3 || len(kept) != 3 {
t.Fatalf("said %d, kept %d, want 3 each", len(said), len(kept))
}
if !strings.Contains(said[1], fp) || !strings.Contains(said[1], `"node":"laptop"`) {
t.Fatalf("the report is not said whole: %s", said[1])
}
failing = true
if got, err := s.Say(start, false); err == nil || got {
t.Fatal("a failed event counts as said")
}
failing = false
if got, _ := s.Say(start, false); !got {
t.Fatal("what failed to be said is not said at the next call")
}
}
@@ -2,14 +2,8 @@ package main
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"os"
"reflect"
"slices"
@@ -17,6 +11,10 @@ import (
"testing"
"time"
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -57,7 +55,7 @@ func someSeats() []seatdef.Definition {
// saying what its tool replaces, as the seat mesh-module-assigner and the indexer keep them on the bus.
func aMesh(t *testing.T) aBus {
t.Helper()
b := aBus{AssignmentsRead: {}, ModulesRead: {}, ManifestsRead: {}}
b := aBus{AssignmentsRead: {}}
for _, p := range []mesh.Assignments{
{V: 1, Node: "anchor", Decided: mesh.NodeDecided{PublicDomain: "Anchor.Example"},
Modules: []mesh.PlacedModule{{Module: "docker"}, {Module: "zsh"}}},
@@ -71,21 +69,33 @@ func aMesh(t *testing.T) aBus {
b[AssignmentsRead][k] = v
}
}
return b
}
// fakeCatalog is the catalog half as the tests give it: docker and zsh, docker's manifest saying what its tool replaces.
type fakeCatalog struct {
entries map[string]catalog.CatalogEntry
notYet error
}
func (f fakeCatalog) Entry(module string) (catalog.CatalogEntry, bool, error) {
e, ok := f.entries[module]
return e, ok, f.notYet
}
func (f fakeCatalog) Ready() error { return f.notYet }
func aCatalog() fakeCatalog {
f := fakeCatalog{entries: map[string]catalog.CatalogEntry{}}
for module, manifest := range map[string]string{
"docker": `{"module":"docker","replaces":{"docker_logs":["docker logs"]}}`,
"zsh": `{"module":"zsh"}`,
} {
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(manifest)))
mkey, _ := catalogclient.ManifestsKey(digest)
raw, err := json.Marshal(catalog.ManifestsByDigestValue{V: 1, Module: module, Manifest: manifest})
if err != nil {
t.Fatal(err)
}
b[ManifestsRead][mkey] = raw
key, _ := catalogclient.ModulesKey(module)
b[ModulesRead][key], _ = json.Marshal(catalog.ModulesByModuleValue{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"})
var whole map[string]any
_ = json.Unmarshal([]byte(manifest), &whole)
f.entries[module] = catalog.CatalogEntry{Module: module, Manifest: whole}
}
return b
return f
}
func resolving(name string) []string {
@@ -97,7 +107,7 @@ func resolving(name string) []string {
// controller: the seats' verbs, the placed modules' own tools, every machine with its domains and addresses — a
// loopback address never one of them.
func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
m, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
if err != nil {
t.Fatal(err)
}
@@ -109,7 +119,9 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
t.Errorf("replacements: %s", got)
}
t.Run("the again tool answers the operator and the healers only", func(t *testing.T) {
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.mesh-healers": false, "shanks.mesh-config": true} {
// An operator's session through the mesh MCP server is proved as its machine's tool runner (issue 627, issue 1966).
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.node-tools": false, "shanks.mesh-healers": false,
"shanks.mesh-config": true} {
if err := seatlist.Refusal("claude_code_seat_list_again", seatListAgainCallers, caller); (err != nil) != refused {
t.Errorf("%s: refused=%v, err %v", caller, refused, err)
}
@@ -132,7 +144,7 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
case <-time.After(10 * time.Second):
t.Fatalf("the seat list was not read: %+v", seatList.Status())
}
got, err := ReadMeshToolsFrom(aMesh(t), definedSeats(), resolving)
got, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), definedSeats(), resolving)
var addrs []string
for _, r := range got.Replacements {
addrs = append(addrs, r.Address)
@@ -154,7 +166,7 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
// **The table is generated, not written** (novox/hq ADR 0245): what the records say a verb replaces is a row,
// the machines' seats first, and nothing is rendered where the records say nothing.
func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
m, _ := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
table := InsteadOf(&m)
for _, want := range []string{
"| `<node>/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |",
@@ -204,7 +216,7 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
// Asked again with the same answer, nothing is written and nothing renders.
func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) {
p := Paths{State: t.TempDir()}
m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
m, _ := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
if !KeepMeshTools(p, m) {
t.Fatal("the first answer was not kept")
}
@@ -241,7 +253,7 @@ func TestTheMachinesRunningAModuleAreReadFromAssignments(t *testing.T) {
func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
bus := aMesh(t)
r := newMeshViews()
for _, name := range []string{ModulesRead, ManifestsRead, AssignmentsRead} {
for _, name := range []string{AssignmentsRead} {
state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".")
values := map[string]json.RawMessage{}
for k, v := range bus[name] {
@@ -255,16 +267,20 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
t.Fatal(err)
}
}
want, err := ReadMeshToolsFrom(bus, someSeats(), resolving)
want, err := ReadMeshToolsFrom(bus, aCatalog(), someSeats(), resolving)
if err != nil {
t.Fatal(err)
}
got, err := ReadMeshToolsFrom(viewStates{r}, someSeats(), resolving)
got, err := ReadMeshToolsFrom(viewStates{r}, aCatalog(), someSeats(), resolving)
if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 {
t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want)
}
// A reader not yet answered says so, and reads nothing.
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
// A catalog half with no first view says so, and reads no module as absent.
if _, err := ReadMeshToolsFrom(bus, fakeCatalog{notYet: errors.New("the catalog half has no first view of main yet")}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
t.Fatalf("an unanswered catalog half: %v", err)
}
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, aCatalog(), someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
t.Fatalf("an unanswered first view: %v", err)
}
}
@@ -272,7 +288,7 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
// Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them,
// name no address of the controller, which is not placed: each job is named by the seat that owns it now.
func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) {
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
m, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
if err != nil {
t.Fatal(err)
}
@@ -9,5 +9,5 @@ func instructionsText(node, role string) string {
node +
"`\n- **Role:** " +
role +
"\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
"\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}
@@ -0,0 +1,19 @@
package main
import (
"os"
"strings"
"testing"
)
// The operator channel's notify, which notifyOperator calls, is in the manifest's invokes, which the grants part reads per
// verb (design 47 G GR8): without it the call is refused live.
func TestTheManifestInvokesTheNotifyTheModuleCalls(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"seat:operator-channel.notify"`) {
t.Error("the manifest does not invoke seat:operator-channel.notify")
}
}
+110 -41
View File
@@ -20,11 +20,13 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -217,12 +219,19 @@ func nodesRunningMe() ([]string, error) { return NodesRunning(viewStates{meshVie
// holds them equal).
const (
assignerModule = "mesh-delivery-assigner"
indexerModule = "mesh-delivery-indexer"
)
// The seat verbs the follower of the watched branches asks (design 47 G §614), as the manifest's `invokes` names them.
const (
RevisionVerb = "seat:mesh-catalog.revision"
RegistrationsVerb = "seat:mesh-catalog.registrations"
SourceVerb = "seat:mesh-delivery.source"
)
// meshCatalog is this module's reading of which modules exist: the catalog half, set where the reader is built.
var meshCatalog CatalogHalf
const (
ModulesTool = indexerModule + ".indexer_read_modules"
ManifestsTool = indexerModule + ".indexer_read_manifests"
AssignmentsTool = assignerModule + ".assigner_read_assignments"
)
@@ -230,12 +239,22 @@ const (
// once of the owner's read tool, then followed on the owner's views family. It reads none of their state.
var meshViews *views.Reader
// newFindingsWriter is this module's finding writer (design 47 G §689, A-16): it states the findings of its views reader and
// observes none of its own.
func newFindingsWriter() (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) {
return views.NewStdioWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{
Asked: condseat.SubjectFindingsAsked, Module: "claude-code", State: stdio.State("findings"),
Observe: func(context.Context) (views.Observed[condseat.Finding], error) {
return views.Observed[condseat.Finding]{}, nil
}})
}
// meshChanged is told, without waiting, that a followed state changed: the instructions' table is read again.
var meshChanged = make(chan struct{}, 1)
// newMeshViews builds the reader of those three states; Start asks them.
func newMeshViews() *views.Reader {
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")},
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) },
Acts: handActs{},
Changed: func(string, string, json.RawMessage, bool) {
select {
@@ -243,8 +262,6 @@ func newMeshViews() *views.Reader {
default:
}
}}
v.Follow(indexerModule, "modules", ModulesTool)
v.Follow(indexerModule, "manifests", ManifestsTool)
v.Follow(assignerModule, "assignments", AssignmentsTool)
return v
}
@@ -325,7 +342,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after reading the bus again for what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).",
Run: func(map[string]any) (any, error) {
answer := map[string]any{}
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup); err != nil {
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, meshCatalog, definedSeats(), lookup); err != nil {
answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered"
} else {
KeepMeshTools(p, m)
@@ -345,7 +362,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
return GuardStatus(p, last), nil
}},
{Name: "claude_code_pull",
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
Description: "Ask the licence manager which licence this node holds and the vault for its access token now, and apply it, rather than waiting for its binding or the token to change.",
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
{Name: "claude_code_grant",
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
@@ -459,6 +476,20 @@ func main() {
}
p, launched := PathsFrom(os.Getenv)
meshViews = newMeshViews()
branches, err := catalogclient.FollowBranches(meshViews, func(key string, body any) (json.RawMessage, error) { return stdio.Ask(key, body) })
if err != nil {
say("%v", err)
os.Exit(1)
}
branches.Logf = say
// A change in a branch's half is told as any followed state's: the instructions' table is read again.
branches.Changed = func(string, string, json.RawMessage, bool, time.Time) {
select {
case meshChanged <- struct{}{}:
default:
}
}
meshCatalog = branches
if launched {
fsys = FilesFor(p)
}
@@ -488,6 +519,20 @@ func main() {
}
}
}
findings, err := newFindingsWriter()
if err != nil {
say("%v", err)
os.Exit(1)
}
meshViews.Findings = findings
// the runtime answers the writer's subscription only once Serve has its channel
go func() {
<-stdio.Served()
if err := findings.Start(context.Background()); err != nil {
say("%v", err)
os.Exit(1)
}
}()
meshViews.Start(context.Background())
go refreshMeshTools(p)
servers := stateOf{stdio.State("servers")}
@@ -567,15 +612,24 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
// the old inode would go quiet. Fingerprints and expiries only.
holdings := stdio.State("holdings")
reported := ""
report := func() {
var sayMu sync.Mutex
sayer := &HoldingsSayer{
Put: func(h Holdings) error { _, err := holdings.Put(p.Node, h); return err },
Emit: func(h Holdings) error { return stdio.Emit(HoldingsChanged, h) },
}
sayHoldings := func(again bool) {
sayMu.Lock()
defer sayMu.Unlock()
now := HoldingsOf(p)
raw, _ := json.Marshal(now)
if string(raw) == reported {
return
}
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
reported = string(raw)
said := false
persist("reporting what this node holds", func() error {
var err error
said, err = sayer.Say(now, again)
return err
}, func(int) {
if !said {
return
}
account := "no account"
if now.Identity != nil && now.Identity.EmailAddress != "" {
account = now.Identity.EmailAddress
@@ -593,30 +647,43 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
say("%s", line)
})
}
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
// generation is fetched with the seat's `current`, sealed to this module's key.
go persist("watching this node's licence binding", func() error {
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
if c.Key != p.Node {
return nil
}
var b *BindingState
if c.Op == "put" {
b = &BindingState{}
if err := json.Unmarshal(c.Value, b); err != nil {
return nil
}
}
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
say("fetching this node's token failed: %v", err)
} else if done != "" {
say("%s", done)
}
go report()
report := func() { sayHoldings(false) }
// The licence manager asks every node to say what it holds again (§23): after its carry-over, at its start and on
// its resync.
go persist("hearing the licence manager ask what this node holds", func() error {
return stdio.Subscribe(HoldingsAsked, func(stdio.Envelope) error {
go sayHoldings(true)
return nil
})
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
}, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) })
// What this node should hold (design 39 bus-and-switch §19, §22): the manager's binding.changed naming this node's
// consumer, and its first view, the seat's bindings for this consumer, asked once each time the events are heard
// anew (the start, or a reconnect); the token is asked from the vault at that first view and on a newer generation.
applied := func(done string, err error) {
if err != nil {
say("fetching this node's token failed: %v", err)
} else if done != "" {
say("%s", done)
}
go report()
}
// Adopted by this module's views reader (design 47 G A-134): the reader hears binding.changed.<consumer> and asks the
// first view once its subscription is taken, and raises and clears that view's findings through the module's writer.
if err := meshViews.Adopt(bindingsFollower(p, ask, writeManaged, applied), Manager, BindingChangedEvent,
func(_ context.Context, body []byte) error {
applied(OnBindingChanged(p, body, ask, writeManaged))
return nil
}); err != nil {
say("this node's licence binding is not followed: %v", err)
}
// The token of the licence this node holds, asked again when the vault says its access secret changed (§19).
go persist("hearing the vault's changes of this node's licence token", func() error {
return stdio.Subscribe(SecretChanged, func(e stdio.Envelope) error {
applied(OnSecretChanged(p, e.Body, ask, writeManaged))
return nil
})
}, func(n int) { say("hearing %s%s", SecretChanged, refusals(n)) })
report()
var last string
@@ -667,8 +734,10 @@ func refusals(n int) string {
return fmt.Sprintf(" (after %d refusal(s))", n)
}
// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7).
var seatListAgainCallers = []string{stdio.AnyPerson, "mesh-healers"}
// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7). A call through
// the mesh MCP server is proved only as its machine's tool runner (<node>.node-tools, issue 627), so node-tools stands for
// an operator's session until a session is proved (issue 1966).
var seatListAgainCallers = []string{stdio.AnyPerson, "node-tools", "mesh-healers"}
// seatListTools are the seat list reader's two tools: what the list holds and what it waits for, and a read now. The
// reader starts once the runtime serves, after the tools are listed, so each tool finds it when it is called.
+16 -35
View File
@@ -16,10 +16,6 @@ import (
"encoding/json"
"errors"
"fmt"
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"net"
"os"
"path/filepath"
@@ -28,6 +24,9 @@ import (
"sync"
"time"
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
@@ -93,8 +92,6 @@ func ReadMeshTools(p Paths) *MeshTools {
// The states read, each declared in the manifest's reads.
const (
AssignmentsRead = (assignerModule + "." + mesh.StateAssignments)
ModulesRead = indexerModule + "." + catalog.StateModules
ManifestsRead = indexerModule + "." + catalog.StateManifests
)
// States reads the module states the manifest declares in its reads: a state's keys, and one key's value.
@@ -152,44 +149,28 @@ func NodesRunning(st States, module string) ([]string, error) {
return runsOn(placed)[module], nil
}
// modulesTools is every placed module's own tools that replace a command, by module, from the manifest the indexer
// keeps of it. A module whose manifest cannot be read is left out and said in the error.
func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
// modulesTools is every placed module's own tools that replace a command, by module, from the manifest its catalog
// graph entry carries. A module whose manifest cannot be read is left out and said in the error.
func modulesTools(cat CatalogHalf, on map[string][]string) ([]ModuleTools, error) {
var out []ModuleTools
var problems []error
if err := cat.Ready(); err != nil {
return nil, err
}
modules := make([]string, 0, len(on))
for m := range on {
modules = append(modules, m)
}
sort.Strings(modules)
for _, module := range modules {
key, err := catalogclient.ModulesKey(module)
if err != nil {
continue
}
raw, found, err := st.Get(ModulesRead, key)
entry, found, err := cat.Entry(module)
if err != nil || !found {
if err != nil {
problems = append(problems, fmt.Errorf("%s %s: %w", ModulesRead, module, err))
problems = append(problems, fmt.Errorf("the catalog half's entry of %s: %w", module, err))
}
continue
}
m, err := catalogclient.DecodeModule(raw)
if err != nil || m.Manifest == "" {
continue
}
mkey, err := catalogclient.ManifestsKey(m.Manifest)
if err != nil {
continue
}
raw, found, err = st.Get(ManifestsRead, mkey)
if err != nil || !found {
if err != nil {
problems = append(problems, fmt.Errorf("%s of %s: %w", ManifestsRead, module, err))
}
continue
}
kept, err := catalogclient.DecodeManifest(raw)
kept, err := json.Marshal(entry.Manifest)
if err != nil {
problems = append(problems, fmt.Errorf("the manifest of %s: %w", module, err))
continue
@@ -197,7 +178,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
var man struct {
Replaces map[string][]string `json:"replaces"`
}
if json.Unmarshal([]byte(kept.Manifest), &man) != nil || len(man.Replaces) == 0 {
if json.Unmarshal(kept, &man) != nil || len(man.Replaces) == 0 {
continue
}
out = append(out, ModuleTools{Module: module, On: on[module], Replaces: man.Replaces})
@@ -263,14 +244,14 @@ func lookup(name string) []string {
// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node assignments names
// with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept.
func ReadMeshToolsFrom(st States, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) {
func ReadMeshToolsFrom(st States, cat CatalogHalf, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) {
var m MeshTools
placed, err := assignments(st)
if err != nil {
m.Replacements = ReplacementsOf(defined, nil)
return m, err
}
modules, merr := modulesTools(st, runsOn(placed))
modules, merr := modulesTools(cat, runsOn(placed))
m.Replacements = ReplacementsOf(defined, modules)
for node, p := range placed {
machine := Machine{Name: node, Domains: []string{node + InternalSuffix}}
@@ -357,7 +338,7 @@ func refreshMeshTools(p Paths) {
}
startSeatList(seatlist.StdioBus())
for {
m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup)
m, err := ReadMeshToolsFrom(viewStates{meshViews}, meshCatalog, definedSeats(), lookup)
if err != nil {
say("reading what each tool replaces and which machines the mesh has: %v", err)
}
+145 -28
View File
@@ -10,12 +10,15 @@ package main
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
// - What this node should hold is the manager's binding, heard as `binding.changed` and asked at start; the
// seat's `current` says which licence, and its `access` secret is asked from the vault, sealed to this
// module's key, at start, on a newer generation and on the vault's `secret-changed` for it, and written
// access-token-only (design 39 bus-and-switch §19).
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
// every node, `<node>.<server>` for one — which every node watches.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -28,14 +31,27 @@ import (
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/views"
)
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
// Seat is the licence manager's role, and Manager the module whose events this one consumes.
const (
Seat = "anthropic-licence-manager"
Manager = "claude-licence-manager"
)
// VaultGet is the vault's verb a licence's access secret is asked by, and SecretChanged the vault's event that it
// changed (design 39 bus-and-switch §19, B9).
const (
VaultGet = "seat:mesh-vault.get"
SecretChanged = "mesh-vault.secret-changed"
)
// AccessSecret is the name of a licence's access secret in the vault, by the licence's key as the manager says it
// (§15, B7: the key is carried, never formed here).
func AccessSecret(key string) string { return "licence." + key + ".access" }
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
@@ -309,13 +325,14 @@ type BindingState struct {
By string `json:"by,omitempty"`
}
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
// Current is what the seat answers to `current`: the licence this node is bound to, its key and its account, never
// its token (§18, B7).
type Current struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
Sealed *SealedBox `json:"sealed"`
Identity *Identity `json:"identity"`
Licence string `json:"licence"`
Key string `json:"key"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
Identity *Identity `json:"identity"`
}
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
@@ -404,40 +421,50 @@ func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
}
// Pull asks the seat for this node's current token and applies it.
// Pull asks the seat which licence this node holds, then the vault for that licence's access secret, sealed to this
// module's key, and applies it (§19).
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
keys, err := Keypair(p)
if err != nil {
return nil, err
}
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node})
if err != nil {
return nil, err
}
var c Current
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
if err := json.Unmarshal(raw, &c); err != nil || c.Licence == "" || c.Key == "" {
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
}
return Apply(p, c, write)
raw, err = ask(VaultGet, map[string]any{"name": AccessSecret(c.Key), "public_key": keys.PublicKey})
if err != nil {
return nil, err
}
var got struct {
Sealed *SealedBox `json:"sealed"`
}
if err := json.Unmarshal(raw, &got); err != nil || got.Sealed == nil {
return nil, fmt.Errorf("the vault answered no sealed %s", AccessSecret(c.Key))
}
return Apply(p, c, *got.Sealed, write)
}
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
// lives hours, and says so.
//
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
// a node that waited for a number above its own ignored every binding it was sent, its login and the
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
// OnBinding takes this node's binding as the manager said it: the token is fetched when its generation is newer than
// the one applied (§19). A home that holds no token of the applied kind has nothing applied, so it is fetched too. A
// released binding keeps the last token, which lives hours, and says so.
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
if b == nil {
return "this node's binding was released; it keeps its last token until it expires", nil
}
var applied Binding
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence &&
holdsApplied(p, applied.Kind) {
if readJSON(p.binding(), &applied) && b.Generation <= applied.Generation && holdsApplied(p, applied.Kind) {
return "", nil
}
return fetched(p, b, ask, write)
}
// fetched pulls this node's token for binding b and says the line naming it.
func fetched(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
out, err := Pull(p, ask, write)
if err != nil {
return "", err
@@ -455,14 +482,104 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
return string(raw), nil
}
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
// BindingChanged is the manager's event of a binding written or deleted (design 39 bus-and-switch §21), which this
// module consumes (§22).
const BindingChanged = Manager + "." + BindingChangedEvent
// BindingChangedEvent is that event's name as the manager says it, with the consumer as its further part
// (`binding.changed.<consumer>`, design 47 G A-134).
const BindingChangedEvent = "binding.changed"
// bindingsFollower is this node's first view of its licence binding, adopted by the module's views reader (A-134): its
// ask is FirstBinding, whose outcome goes to applied; the picture keeps no binding, which the node's own files hold.
func bindingsFollower(p Paths, ask Ask, write WriteManaged, applied func(string, error)) *views.Follower {
return &views.Follower{Owner: Manager, Tool: SeatVerb("bindings"), State: "bindings",
Ask: func(context.Context, string) (views.Answer, error) {
done, err := FirstBinding(p, ask, write)
if err != nil {
return views.Answer{}, err
}
applied(done, nil)
return views.Answer{Owner: Manager, State: "bindings", Values: map[string]json.RawMessage{}, At: time.Now()}, nil
}}
}
// bindingOf is a consumer's binding as the manager says it, in binding.changed and in the verb bindings' answer: an
// empty licence is a release (B22: decoded from a copy of the writer's form, testdata/binding-changed.json).
type bindingOf struct {
Consumer string `json:"consumer"`
BindingState
}
func (b bindingOf) state() *BindingState {
if b.Licence == "" {
return nil
}
return &b.BindingState
}
// OnBindingChanged takes one binding.changed: acted on only when it names this node's consumer (§22).
func OnBindingChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
var b bindingOf
if err := json.Unmarshal(body, &b); err != nil {
return "", fmt.Errorf("%s is not a binding's change: %w", BindingChanged, err)
}
if b.Consumer != p.Node {
return "", nil
}
return OnBinding(p, b.state(), ask, write)
}
// FirstBinding asks the seat's verb bindings for this node's consumer, its first view (§22, B10), and applies it: at
// the module's start, and again whenever it hears the manager's events anew.
func FirstBinding(p Paths, ask Ask, write WriteManaged) (string, error) {
raw, err := ask(SeatVerb("bindings"), map[string]any{"consumer": p.Node})
if err != nil {
return "", err
}
var listed []bindingOf
if err := json.Unmarshal(raw, &listed); err != nil {
return "", fmt.Errorf("the seat's bindings answered what is not a list of bindings: %w", err)
}
for _, b := range listed {
if b.Consumer == p.Node && b.state() != nil {
// At its start the module asks the vault whatever it applied before (§19).
return fetched(p, b.state(), ask, write)
}
}
return OnBinding(p, nil, ask, write)
}
// secretChanged is the vault's `secret-changed` as it says it: the secret's name and generation, never its value.
type secretChanged struct {
Name string `json:"name"`
Generation int64 `json:"generation"`
}
// OnSecretChanged takes one `secret-changed`: acted on only when it names the access secret of the licence this node
// holds, which is then asked again (§19).
func OnSecretChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
var e secretChanged
if err := json.Unmarshal(body, &e); err != nil {
return "", fmt.Errorf("%s is not a secret's change: %w", SecretChanged, err)
}
var applied Binding
if !readJSON(p.binding(), &applied) || applied.Key == "" || e.Name != AccessSecret(applied.Key) {
return "", nil
}
return fetched(p, &BindingState{Licence: applied.Licence, Kind: applied.Kind, Generation: applied.Generation,
Why: "the vault's " + e.Name + " changed"}, ask, write)
}
// Apply applies the access secret the vault handed over for the licence the seat named. A switch replaces the grant
// whole and cleans up after the old licence; whatever it is, the file is written without a refresh token, so the
// agent here never refreshes.
func Apply(p Paths, c Current, sealed SealedBox, write WriteManaged) (map[string]any, error) {
keys, err := Keypair(p)
if err != nil {
return nil, err
}
plain, err := Open(*c.Sealed, keys.PrivateKey)
plain, err := Open(sealed, keys.PrivateKey)
if err != nil {
return nil, err
}
@@ -522,7 +639,7 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
if gen == 0 {
gen = previous.Generation
}
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
raw, _ := json.Marshal(Binding{Licence: c.Licence, Key: c.Key, Kind: c.Kind, Generation: gen})
if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
return nil, err
}
@@ -906,10 +906,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
if why := approverReady(bus); why != "" {
return refuse(why + "; " + AtTheTerminalInstead)
}
if waiting := waitingProposals(store, approved, now); waiting >= occ.MostOpen {
return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+
"questions at once; claude_code_proposals lists them", waiting, occ.MostOpen))
}
if problem := ProposalProblem(prop); problem != "" {
return refuse(problem)
}
@@ -956,26 +952,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
}, nil
}
// waitingProposals counts the proposals still open: asked, not decided here, not expired.
func waitingProposals(store ProposalStore, approved Approved, now time.Time) int {
keys, err := store.Keys()
if err != nil {
return 0
}
n := 0
for _, k := range keys {
prop, ok := readProposal(store, k)
if !ok || prop.Asked != "" {
continue
}
if _, decided := approved.Decided[prop.ID]; decided || now.After(prop.At.Add(ProposalLasts)) {
continue
}
n++
}
return n
}
func readProposal(store ProposalStore, key string) (Proposal, bool) {
raw, found, err := store.Get(key)
if err != nil || !found {
@@ -21,7 +21,6 @@ import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"sync"
@@ -38,26 +37,8 @@ const QuestionsState = "questions"
// QuestionPrefix starts every question's key, so the router's word on one is never taken for a proposal's.
const QuestionPrefix = "q-"
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
// (occ.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by
// create or by compare-and-set, so two nodes asking at once cannot both take the last one.
const MostQuestions = occ.MostOpen - 1
// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires:
// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can
// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it
// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it
// when it is taken (oc.Ask.Check), so the slot is free and the question has ended.
// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it.
func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) }
// slot is what a slot key holds: the question holding it.
type slot struct {
Question string `json:"question"`
At time.Time `json:"at"`
}
// A question is never refused for how many wait: the router offers an asker at most three asks at once and holds the
// oldest back behind newer ones, offered again when one ends (novox/hq ADR 0666 §12), so no question is hidden here.
// The bounds of a question's life (ADR 0332 §2): at least five minutes, at most a day less ten minutes (the
// router's bound for an approval, with the margin a proposal keeps), an hour when none is given.
@@ -372,19 +353,9 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
if err := occ.Check(ask, now); err != nil {
return refuse(err.Error())
}
// The question is kept before its slot is taken, so a slot always names a question another node can judge.
if err := store.Create(q.ID, q); err != nil {
return nil, fmt.Errorf("the question could not be kept, so the operator was not asked: %w", err)
}
if open, err := takeSlot(store, bus, q, now); err != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
return refuse(err.Error())
} else if open != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: two questions were open"))
return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d "+
"at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+
"claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions))
}
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
return nil, fmt.Errorf("the operator could not be asked: %v", err)
@@ -420,75 +391,6 @@ func labelsOf(q Question) string {
return strings.Join(out, ", ")
}
// questionOpen says whether a question still waits: kept, asked, not ended, not expired, and open in the router's
// record or not recorded yet. An unreadable record is open: a slot is never freed on a guess.
func questionOpen(store QuestionStore, bus AskBus, id string, now time.Time) (bool, error) {
q, ok, err := readQuestionErr(store, id)
if err != nil {
return true, err
}
if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) {
return false, nil
}
entry, err := bus.Record(q.ID)
if err != nil {
return true, err
}
if entry == nil {
return true, nil
}
var rec routerRecord
if json.Unmarshal(entry.Value, &rec) != nil {
return true, nil
}
return rec.State == "" || rec.State == "open", nil
}
// takeSlot takes one of the MostQuestions slots for q: a slot no key holds, by create, or one whose question no longer
// waits, by compare-and-set at the revision read. Of two nodes taking the same slot only one wins; the other tries
// the next. It answers the open questions when every slot is held, and an error — refusing, never guessing — when a
// slot or its question cannot be read.
func takeSlot(store QuestionStore, bus AskBus, q Question, now time.Time) ([]string, error) {
mine := slot{Question: q.ID, At: q.At}
var open []string
for n := 1; n <= MostQuestions; n++ {
key := slotKey(n)
raw, rev, found, err := store.Get(key)
if err != nil {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if !found {
if err := store.Create(key, mine); err == nil {
return nil, nil
} else if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if raw, rev, found, err = store.Get(key); err != nil || !found {
return nil, fmt.Errorf("the open questions could not be counted, so none more is asked")
}
}
var held slot
if json.Unmarshal(raw, &held) == nil && held.Question != "" {
waits, err := questionOpen(store, bus, held.Question, now)
if err != nil {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if waits {
open = append(open, held.Question)
continue
}
}
if err := store.Update(key, mine, rev); err == nil {
return nil, nil
} else if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
open = append(open, "one taken just now")
}
sort.Strings(open)
return open, nil
}
// tombstone is what is kept of a question that was not asked or that the router refused: its key, who asked, when, and
// why it ended — none of its words, which the router judged unfit to leave the mesh or never saw.
func tombstone(q Question, ended string) Question {
@@ -608,7 +510,7 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
case entry == nil:
out["state"] = AnswerWaiting
out["words"] = "not yet recorded by the router: it may still be in the router's queue, or was refused without the " +
"refusal being heard here. It holds its slot, and the operator may still be asked, until it expires at " +
"refusal being heard here. The operator may still be asked until it expires at " +
q.Expires.Format(time.RFC3339)
case entry == nil, rec.State == "open", rec.Warrant == nil:
out["state"] = AnswerWaiting
@@ -6,7 +6,6 @@ package main
import (
"encoding/json"
"errors"
"strings"
"sync"
"testing"
@@ -182,22 +181,21 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
}
}
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
// ADR 0666 §12: a question is never refused for how many wait; the router holds the oldest back, offered again when one ends.
func TestAThirdQuestionIsAskedNotRefusedAndAProposalIsAskedBesideThem(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "2 questions already wait") {
t.Fatalf("a third question: %v", err)
for i := 0; i < 4; i++ {
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err != nil {
t.Fatalf("question %d: %v", i+1, err)
}
}
r.close(r.chose(first, "o1", t0.Add(time.Minute)))
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute)); err != nil {
t.Fatalf("a question once one was answered: %v", err)
if len(r.subjects) != 4 {
t.Errorf("the router was asked %d times, want 4", len(r.subjects))
}
// A proposal is still asked beside two waiting questions.
pstore := &memProposals{m: map[string]json.RawMessage{}}
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, pstore, r, t0); err != nil {
t.Errorf("a proposal beside two questions: %v", err)
t.Errorf("a proposal beside the questions: %v", err)
}
}
@@ -234,9 +232,6 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
if err != nil || out["state"] != AnswerEnded || !strings.Contains(out["words"].(string), "refused") {
t.Errorf("read after the refusal: %v %v", out, err)
}
if waits, err := questionOpen(store, refusing, refusing.id, t0.Add(time.Minute)); waits || err != nil {
t.Errorf("a refused question still counts as waiting: %v %v", waits, err)
}
var kept Question
_ = json.Unmarshal(store.m[refusing.id], &kept)
if kept.Headline != "" || kept.Context != "" || len(kept.Options) != 0 || kept.Why != "" || kept.OnExpiry != "" {
@@ -316,10 +311,6 @@ type memQuestions struct {
m map[string]json.RawMessage
rev map[string]uint64
n uint64
// failGet fails every read of a key with this prefix.
failGet string
// afterGet runs after a read, outside the lock: another node writing between a read and a write.
afterGet func(key string)
}
func newMemQuestions() *memQuestions {
@@ -328,17 +319,9 @@ func newMemQuestions() *memQuestions {
func (s *memQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
s.mu.Lock()
if s.failGet != "" && strings.HasPrefix(key, s.failGet) {
s.mu.Unlock()
return nil, 0, false, errors.New("the bus did not answer")
}
defer s.mu.Unlock()
raw, ok := s.m[key]
rev, after := s.rev[key], s.afterGet
s.mu.Unlock()
if after != nil {
after(key)
}
return raw, rev, ok, nil
return raw, s.rev[key], ok, nil
}
func (s *memQuestions) put(key string, value any) error {
raw, err := json.Marshal(value)
@@ -397,121 +380,3 @@ func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
}
}
}
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
nodes := []string{"g14", "shanks", "ace"}
var wg sync.WaitGroup
errs := make([]error, len(nodes))
for i, name := range nodes {
p, _ := node(t, name)
wg.Add(1)
go func(i int, p Paths) {
defer wg.Done()
in := mergeTonight()
in.By = "fix-589"
_, errs[i] = AskQuestion(p, in, store, r, t0)
}(i, p)
}
wg.Wait()
refused := 0
for _, err := range errs {
if err != nil {
if !strings.Contains(err.Error(), "questions already wait") {
t.Errorf("refused for another reason: %v", err)
}
refused++
}
}
if refused != 1 {
t.Errorf("%d of three questions asked at once were refused; one is", refused)
}
}
// A slot that cannot be read refuses the question: the cap fails closed.
func TestTheCapFailsClosed(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
store.failGet = "slot-"
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
t.Fatalf("asked with the slots unreadable: %v", err)
}
if len(r.subjects) != 0 {
t.Error("the operator was asked anyway")
}
}
// A question whose refusal was not heard here, and of which the router holds no record, keeps its slot and reads as
// waiting until it expires — the router may still take it from its queue — and only then frees its slot and ends.
func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
delete(r.records, first) // the router down or backlogged, or its refusal never heard here
later := t0.Add(QuestionDefault - time.Minute)
if _, err := AskQuestion(p, mergeTonight(), store, r, later); err == nil {
t.Fatal("the slot of a question the router may still show was freed before it expired")
}
out, _ := ReadAnswer(store, r, first, later)
if out["state"] != AnswerWaiting || !strings.Contains(out["words"].(string), "not yet recorded") {
t.Errorf("before expiry read as %v", out)
}
expired := t0.Add(QuestionDefault)
if out, _ := ReadAnswer(store, r, first, expired); out["state"] != AnswerEnded ||
out["now"] != "do what you said: I leave the pull request open" {
t.Errorf("at expiry read as %v", out)
}
if _, err := AskQuestion(p, mergeTonight(), store, r, expired); err != nil {
t.Errorf("the slot of an expired question was not freed: %v", err)
}
}
// A slot is taken only at the revision read: when another node takes both slots between this node's read and its
// write, this node's writes are refused and the question is refused — a plain write would take a slot it lost.
func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
// Two questions that ended: their slots are free to take again.
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(time.Minute))})
}
shanks, _ := node(t, "shanks")
other := newRouter()
store.afterGet = func(key string) {
if !strings.HasPrefix(key, "slot-") {
return
}
// Another node asks between this node's read of the slot and its write: its question waits.
q := Question{ID: newQuestionID(), Headline: "Another", Context: "c", Why: "w", OnExpiry: "e", By: "other",
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
_ = store.Put(q.ID, q)
_ = other.Publish(oc.AcceptSubjectAsk(Module), q.Ask(), q.ID)
r.records[q.ID] = other.records[q.ID]
_ = store.Put(key, slot{Question: q.ID, At: t0})
}
_, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute))
store.afterGet = nil
if err == nil || !strings.Contains(err.Error(), "questions already wait") {
t.Fatalf("a slot lost to another node was taken: %v", err)
}
}
// lockedRouter is the router fake, safe for sessions asking at once.
type lockedRouter struct {
mu sync.Mutex
*router
}
func (l *lockedRouter) Publish(subject string, body any, id string) error {
l.mu.Lock()
defer l.mu.Unlock()
return l.router.Publish(subject, body, id)
}
func (l *lockedRouter) Record(id string) (*stdio.StateEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.router.Record(id)
}
@@ -50,7 +50,9 @@ type Settings struct {
// Binding is the licence this node holds, as it was last applied.
type Binding struct {
Licence string `json:"licence"`
Licence string `json:"licence"`
// Key is the licence's key as the manager says it, which names its access secret in the vault (§19, B7).
Key string `json:"key,omitempty"`
Kind string `json:"kind"` // subscription | api-key
Generation int64 `json:"generation,omitempty"`
}
@@ -2,11 +2,12 @@ package main
import (
"encoding/json"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"strings"
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// A section registered for the mesh before the controller's terminal took that over, or set at the terminal, is
+4 -5
View File
@@ -60,13 +60,12 @@ func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
}
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook
// cannot know, so it is offered conditionally; a watcher that rereads the read address always works.
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, design 47 E §20): a wait with an event is
// followed by mesh_wait on it, rereading what it reads after every answer, or by a watcher that does; with no event, by a
// watcher that rereads the read address. The advice puts no condition on a grant.
func watcherAdvice(w Wait) string {
if w.Family != "" {
return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+
"that rereads %s", w.Family, w.Read.Address)
return fmt.Sprintf("mesh_wait on %s and reread %s after every answer, or start a watcher that does", w.Family, w.Read.Address)
}
return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address)
}
@@ -300,15 +300,15 @@ func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
}
}
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
// runner may not be granted it); without one, the advice is only to start a watcher.
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
// Design 47 E §20: a wait with an event is followed by mesh_wait on it and a reread after every answer, or a watcher
// that does, with no condition on a grant; without an event, the advice is only to start a watcher.
func TestWaitBlocksStopAdvisesMeshWaitOnItsEventOtherwiseAWatcher(t *testing.T) {
withFamily := openWait("w-f", 10)
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
withFamily.Family = "mesh-delivery.decided"
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided and reread mesh-delivery.recheck after every answer, or start a watcher that does") ||
strings.Contains(reason, "granted") {
t.Fatalf("%s", reason)
}
@@ -0,0 +1 @@
{"at":"2026-10-04T12:00:00Z","by":"g14.node-tools","consumer":"laptop","from":"acct-1@example.org","generation":3,"key":"api","kind":"api-key","licence":"api","why":"the switch verb"}
@@ -32,11 +32,11 @@
"withKey": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
},
"plain": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}
}
+7 -4
View File
@@ -7,12 +7,11 @@ require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
gopkg.in/yaml.v3 v3.0.1
)
@@ -26,6 +25,10 @@ require (
)
require (
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect
)
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect
+49 -14
View File
@@ -1,6 +1,6 @@
{
"module": "claude-code",
"version": "1",
"version": "0.1",
"moved": true,
"slug": "agent",
"capabilities": [
@@ -11,6 +11,25 @@
{
"name": "gitea-read",
"optional": true
},
{
"name": "finding-events",
"storage": "stream",
"template": "events",
"options": {
"events": [
"finding-written.*",
"finding-cleared.*",
"findings-stated.*"
],
"max-age-days": 7,
"max-mib": 16
}
},
{
"name": "findings",
"storage": "key-value",
"template": "state"
}
],
"binds": {
@@ -26,16 +45,26 @@
"seat:mesh-hand-acts.warranted",
"seat:mesh-hand-acts.hand-act",
"seat:mesh-catalog.seat-definitions",
"mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-assigner.assigner_read_assignments"
"seat:mesh-catalog.revision",
"seat:mesh-catalog.registrations",
"seat:mesh-delivery.source",
"mesh-delivery-assigner.assigner_read_assignments",
"seat:anthropic-licence-manager.bindings",
"seat:anthropic-licence-manager.current",
"seat:mesh-vault.get",
"seat:operator-channel.notify"
],
"consumes": [
"mesh-delivery-indexer.views.modules",
"mesh-delivery-indexer.views.manifests",
"mesh-catalog.catalog-revision",
"mesh-catalog.registration-changed",
"mesh-delivery.source-moved",
"mesh-delivery-assigner.views.assignments",
"mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed"
"mesh-catalog.seat-definition-removed",
"mesh-conditions.findings-asked",
"claude-licence-manager.holdings.asked",
"claude-licence-manager.binding.changed",
"mesh-vault.secret-changed"
],
"state": [
"servers",
@@ -44,14 +73,11 @@
"proposals",
"questions",
"memory",
"waits",
"findings"
"waits"
],
"emits": [
"judged"
],
"reads": [
"claude-licence-manager.bindings"
"judged",
"holdings-changed"
],
"tools": [
"claude_code_status",
@@ -233,7 +259,16 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "${machine:account}",
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
"takes-config": [
"role",
"mcp_servers",
"managed_settings",
"instructions",
"output_styles",
"skills",
"commands",
"agents"
],
"merge": "json",
"template": "files/settings.json"
}
+81 -27
View File
@@ -1,58 +1,112 @@
# claude-licence-manager
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39).
rotation source, its consumers moved off a licence near its limit by the manager itself (novox/hq ADR 0183,
ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module
keeps none.
It is one manifest of four parts (design 39 parts §1): the manager bundle (`manager`, `cmd/manager`), which runs long
(the conditions judged at its start, the usage timer, the switch rule) and answers the seat's verbs and every act; the
tools bundle (`tools`, `cmd/tools`), which answers the reading tools and writes nothing, so they answer while the
manager fails; the settings file; and the module's account with its directory. Both bundles run as an account of its
own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its own, never in the machine's
tool runner, so every state, log and secret below is issued to its own account. Its
account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and
a node's `claude_code_grant`), the events under `emits` and `consumes`.
## How a licence comes to exist
Nothing is configured. Every node running `claude-code` reports what it holds as that module's
`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report
when it starts and watches them:
Nothing is configured. Every node running `claude-code` says what it holds as the event
`claude-code.holdings-changed` (the account, fingerprints and expiries, never a token), at its start, on every change,
and again when this module says `holdings.asked` (at every start, after its conditions are judged, and on `licences_resync`):
1. A report with a refresh token it does not hold is a **candidate**.
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest
with the key the vault made for it — and from then on it is the only refresher. If not, the candidate
is recorded dead and nothing is adopted.
3. **It refreshes it**, under the account's lease. If the vendor exchanges the token, the licence is new, named by its
account's address and kept under that name's key (`@` as `_at_`, any other character outside letters, digits, `-`
and `.` as `_`); its grant and access token go to the vault (`licence-put`), and from then on this module is the
only refresher. If not, the candidate is recorded dead and nothing is adopted. An adoption whose key another licence
has is refused.
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
5. A node reporting that account and bound to nothing is bound to it.
5. The node it came from is bound to it; a node reporting that account and bound to nothing is bound to it.
Each node is then handed an access token only, so the agent there never refreshes, and a refresh token
appearing on a node later can only be a person's login — which wins if it refreshes.
From its next reading a new subscription has a room and is a target of the switch. An API key enters through `adopt`,
from a file on this module's node or sealed by a node's `claude_code_add_api_key`; it is never a target.
An API key enters through `adopt`, from a file on this module's node.
## What it keeps on the bus
## What each consumer holds
Each state asked for in `requires` by its template (ADR 0595), and the two logs declared in the `logs` section, the
form the runtime issues today (design 39 bus-and-switch C3); each read by `licences_kept`:
This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a
generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer
generation, asks `current` with its public key.
| name | template | what |
|---|---|---|
| `licences` | state, history 1 | one key per licence: account, address, organisation, expiries, failures, adoption, the vault's generation, the latest reading; never a token |
| `bindings` | state, history 8 | one key per consumer (a node's name): licence, kind, generation, since, why and by |
| `leases` | state, a lifetime per key | `licence.<key>`, `account.<id>`: one refresh or adoption at a time, two minutes |
| `offered` | state, a lifetime per key | each grant a node offered and its outcome, 90 days |
| `usage` | log, 35 days, 256 MiB | every reading, one subject per licence key, with the vendor's answer whole |
| `audit` | log, 64 MiB | every act, one subject per kind (`switched`, `carried`, `settings` among them) |
| `carried` | state | `postgres`: the record of the carry-over from the store database, which ran once on 2026-11-12; nothing writes it again |
| `conditions` | state | each condition said raised, and since when |
The carry-over from the store database is done, and its code has left the module with its requirement of the
database and of the secret `grant-key` (design 39 parts §9, §10); at its start the manager waits for nothing and
clears a `carry-over-failed` it finds kept.
## Switching on usage
Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session,
weekly and sonnet windows, each with its reset time. A licence's room is 100 less its weekly percentage alone (the session
window takes no part in room, the switch, the warning or the conditions: the vendor retired the session limit), known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
a licence with consumers at or above `switch_at_pct` in its weekly window, every consumer bound to it moves, in one act, to the licence
with the most room whose weekly window is below `room_below_pct`: one `binding.changed` per consumer, one
`licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule.
Its conditions are said on the operator channel's condition subject, the router deciding where they go:
`licence.<key>.no-room` (urgent; no licence has room), `licence.<key>.near-limit` (at `warn_at_pct`, once per
crossing), `licence.<key>.usage-unread` and `settings.<setting>.invalid`.
## The seat's verbs
`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through
the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
`licences`, `bindings` (with `consumer` for one), `bind`, `switch`, `release`, `refresh`, `usage` (`licence`,
`since`, `limit` up to 2000, `raw`), `adopt`, `public-key`, `current` (the licence, its key, kind, generation and
health, never the token) — through the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
## Its tools
Read by the tools bundle: `licences_switches` (every switch and why), `licences_settings` (each value, its default
and where it was set), `licences_kept` (every entry it keeps, how full against its cap) and `licences_conditions`.
Acting, by the manager bundle: `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the rule now, even
with `switch_auto` off) and `licences_resync` (say every binding, holdings.asked and each condition again, and hand
each access token to the vault again).
## Settings
`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60),
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3).
Through mesh-config, each `trusted`, read again at every reading: `cadence_minutes` (240), `floor_minutes` (60),
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3), `switch_auto` (on), `switch_at_pct` (99,
1 to 100), `room_below_pct` (90, 1 to 100), `warn_at_pct` (90, 1 to 100), `usage_minutes` (5, 1 to 60),
`usage_stale_minutes` (15, 5 to 180). A value outside its bound keeps its default and raises its condition.
## Events
`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret.
`licence.adopted`, `licence.refused`, `licence.switched`, `licence.failing`, `usage.read`, `binding.changed`,
`holdings.asked` — none carries a secret. It consumes `claude-code.holdings-changed`.
## Code and tests
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle,
`prepare` as the run-once preparation step. The sealed box is `claude-code`'s own format, byte for byte —
the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the
Go one replaced, so the format is the one already on the machines.
Go, one Go module of two binaries: `cmd/manager` (`claude-licence-manager`: the seat's verbs, the acting tools and the
daemon) and `cmd/tools` (`claude-licence-manager-tools`: the reading tools), sharing the package `internal/licences`
(the layouts of the kept data and the log entries, the key form, the entries' names and the settings). The
sealed box is `claude-code`'s own format, byte for byte — the two modules carry the same `seal.go` — and a test opens
one sealed by the TypeScript agent module the Go one replaced, so the format is the one already on the machines.
go test ./...
# the store against a real postgres:
docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
# the whole module on a real bus:
go test -tags integration ./...
The whole tests (`cmd/manager/whole_integration_test.go`) start the built binaries as their runtime does, its kept data in a real
nats-server. The vault, the agent modules, the
operator channel and the vendor are played beside it; a condition is read through the operator channel's client.
## Files it does not know
@@ -1,64 +0,0 @@
package main
// The grants at rest (novox/hq ADR 0183): encrypted with a key the vault made for this module, so the
// store holds ciphertext and only this module, reading its own secret, can open a row. AES-256-GCM, the
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"os"
"strings"
)
// Crypt seals and opens what the store keeps.
type Crypt struct{ key []byte }
// NewCrypt is the store's cipher from the vault's secret.
func NewCrypt(secret string) (*Crypt, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return nil, errors.New("the key the grants are encrypted with is empty: the vault has not delivered it yet")
}
sum := sha256.Sum256([]byte(secret))
return &Crypt{key: sum[:]}, nil
}
// CryptFromFile reads the vault's secret from the file the mesh delivered it to.
func CryptFromFile(path string) (*Crypt, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return NewCrypt(string(raw))
}
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`.
func (c *Crypt) Seal(plaintext string) string {
gcm, _ := newGCM(c.key)
iv := make([]byte, 12)
_, _ = rand.Read(iv)
b64 := base64.StdEncoding.EncodeToString
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
}
// Open decrypts what Seal made with the same key.
func (c *Crypt) Open(sealed string) (string, error) {
parts := strings.Split(sealed, ".")
if len(parts) != 3 || parts[0] != "v1" {
return "", errors.New("not a grant this module sealed")
}
iv, err1 := base64.StdEncoding.DecodeString(parts[1])
ct, err2 := base64.StdEncoding.DecodeString(parts[2])
if err := errors.Join(err1, err2); err != nil {
return "", err
}
gcm, _ := newGCM(c.key)
plain, err := gcm.Open(nil, iv, ct, nil)
if err != nil {
return "", errors.New("the grant does not open with this module's key")
}
return string(plain), nil
}
@@ -1,291 +0,0 @@
package main
// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this
// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each
// statement idempotent.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Schema is what this version needs.
var Schema = []string{
`create table if not exists licence (
name text primary key,
kind text not null check (kind in ('subscription', 'api-key')),
account_uuid text unique,
email text,
organization_uuid text,
sealed text,
refresh_fingerprint text,
access_expires_at bigint,
refresh_expires_at bigint,
failures integer not null default 0,
notified_at bigint,
adopted_at bigint not null,
rotated_at bigint)`,
`create sequence if not exists binding_generation`,
`create table if not exists binding (
consumer text primary key,
licence text not null references licence(name),
generation bigint not null)`,
`create table if not exists lease (
key text primary key,
holder text not null,
until timestamptz not null)`,
`create table if not exists offered (
fingerprint text primary key,
node text not null,
account_uuid text,
outcome text not null,
why text not null,
at timestamptz not null default now())`,
`create table if not exists usage (
licence text not null,
at bigint not null,
reading jsonb not null,
raw jsonb not null)`,
`create index if not exists usage_by_licence on usage (licence, at desc)`,
`create table if not exists audit (
at timestamptz not null default now(),
what text not null,
detail jsonb not null)`,
}
// PgStore is the store on postgres.
type PgStore struct{ pool *pgxpool.Pool }
// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
file := os.Getenv("DATABASE_URL_FILE")
if file == "" {
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
}
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
}
// OpenPgStore opens a store at a URL.
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, err
}
return &PgStore{pool: pool}, nil
}
// Migrate brings the schema to this version's shape.
func (s *PgStore) Migrate(ctx context.Context) error {
for _, q := range Schema {
if _, err := s.pool.Exec(ctx, q); err != nil {
return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err)
}
}
return nil
}
const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0),
failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)`
func scanLicence(row pgx.Row) (*Licence, error) {
var l Licence
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return &l, err
}
func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) {
rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Licence
for rows.Next() {
l, err := scanLicence(rows)
if err != nil {
return nil, err
}
out = append(out, *l)
}
return out, rows.Err()
}
func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) {
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name))
}
func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account))
}
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
func nullableInt(v int64) any {
if v == 0 {
return nil
}
return v
}
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint,
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint,
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
rotated_at = excluded.rotated_at`,
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed),
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
return err
}
// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds
// it live and does nothing.
func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) {
tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3))
on conflict (key) do update set holder = excluded.holder, until = excluded.until
where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds())
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (s *PgStore) Unlease(ctx context.Context, key, holder string) error {
_, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder)
return err
}
func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) {
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Binding
for rows.Next() {
var b Binding
if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) {
return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`)
}
func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) {
out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer)
if err != nil || len(out) == 0 {
return nil, err
}
return &out[0], nil
}
func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) {
out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation'))
on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation
returning consumer, licence, generation`, consumer, licence)
if err != nil {
return Binding{}, err
}
return out[0], nil
}
func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer)
return err == nil && tag.RowsAffected() == 1, err
}
func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error {
if generation <= 0 {
return nil
}
// setval with is_called, so the next nextval is generation+1; only ever forward.
_, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation
where not is_called or last_value < $1`, generation)
return err
}
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
returning consumer, licence, generation`, licence)
}
func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) {
var o string
err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return Outcome(o), err
}
func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error {
_, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5)
on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`,
fp, node, nullable(account), string(o), why)
return err
}
func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
reading, _ := json.Marshal(r)
rawJSON, _ := json.Marshal(raw)
_, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON)
return err
}
func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []UsageRow
for rows.Next() {
var u UsageRow
var reading []byte
if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil {
return nil, err
}
_ = json.Unmarshal(reading, &u.Reading)
out = append(out, u)
}
return out, rows.Err()
}
func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error {
raw, _ := json.Marshal(detail)
_, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw)
return err
}
func (s *PgStore) Close() { s.pool.Close() }
@@ -1,43 +0,0 @@
package main
import (
"encoding/json"
"os"
"testing"
)
// A box the agent module's TypeScript sealed opens here: the two implementations are one format.
func TestABoxSealedInTypeScriptOpensInGo(t *testing.T) {
raw, err := os.ReadFile("testdata/sealed-by-typescript.json")
if err != nil {
t.Fatal(err)
}
var f struct {
PrivateKey string `json:"privateKey"`
Box SealedBox `json:"box"`
Plaintext string `json:"plaintext"`
}
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
got, err := Open(f.Box, f.PrivateKey)
if err != nil || got != f.Plaintext {
t.Fatalf("opened %q, %v", got, err)
}
}
// What Go seals opens with its own key and no other.
func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
a, _ := GenerateKeyPair()
b, _ := GenerateKeyPair()
box, err := Seal("a token", a.PublicKey)
if err != nil {
t.Fatal(err)
}
if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" {
t.Fatalf("opened %q, %v", got, err)
}
if _, err := Open(box, b.PrivateKey); err == nil {
t.Fatal("a box opened for another key")
}
}
@@ -1,272 +0,0 @@
package main
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
// with a generation, what became of each login it was offered, usage readings, and the audit. One
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
// rule is tested without a database, and the database is asked only to keep rows.
import (
"context"
"sort"
"sync"
"time"
)
// Licence is one licence: an account, or an API key.
type Licence struct {
Name string `json:"name"`
Kind string `json:"kind"` // subscription | api-key
AccountUUID string `json:"accountUuid,omitempty"`
Email string `json:"email,omitempty"`
OrganizationUUID string `json:"organizationUuid,omitempty"`
Sealed string `json:"-"` // the grant or the key, encrypted at rest
RefreshFingerprint string `json:"-"`
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
Failures int `json:"failures"`
NotifiedAt int64 `json:"-"`
AdoptedAt int64 `json:"adoptedAt"`
RotatedAt int64 `json:"rotatedAt,omitempty"`
}
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
type Binding struct {
Consumer string `json:"consumer"`
Licence string `json:"licence"`
Generation int64 `json:"generation"`
}
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
type Outcome string
const (
Adopted Outcome = "adopted"
Dead Outcome = "dead"
Skipped Outcome = "skipped"
Refused Outcome = "refused"
Gone Outcome = "gone"
)
// UsageRow is one usage reading.
type UsageRow struct {
Licence string `json:"licence"`
At int64 `json:"at"`
Reading UsageReading `json:"reading"`
}
// Store is what the manager keeps.
type Store interface {
Licences(ctx context.Context) ([]Licence, error)
Licence(ctx context.Context, name string) (*Licence, error)
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
SaveLicence(ctx context.Context, l Licence) error
// Lease takes a lease for d, or answers false while another holder's is live.
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
Unlease(ctx context.Context, key, holder string) error
Bindings(ctx context.Context) ([]Binding, error)
Binding(ctx context.Context, consumer string) (*Binding, error)
// Bind binds (or switches) a consumer at the next generation.
Bind(ctx context.Context, consumer, licence string) (Binding, error)
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
Advance(ctx context.Context, licence string) ([]Binding, error)
// GenerationsAbove makes every generation given from now on greater than this one.
GenerationsAbove(ctx context.Context, generation int64) error
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error)
Audit(ctx context.Context, what string, detail map[string]any) error
Close()
}
// MemoryStore is the tests' store.
type MemoryStore struct {
mu sync.Mutex
now func() time.Time
rows map[string]Licence
binds map[string]Binding
leases map[string]struct {
holder string
until time.Time
}
outcomes map[string]Outcome
usage []UsageRow
Audits []map[string]any
generation int64
}
// NewMemoryStore is an empty store whose leases age by now.
func NewMemoryStore(now func() time.Time) *MemoryStore {
return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{},
leases: map[string]struct {
holder string
until time.Time
}{}, outcomes: map[string]Outcome{}}
}
func (m *MemoryStore) Licences(context.Context) ([]Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Licence, 0, len(m.rows))
for _, l := range m.rows {
out = append(out, l)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
if l, ok := m.rows[name]; ok {
return &l, nil
}
return nil, nil
}
func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, l := range m.rows {
if l.AccountUUID == account {
return &l, nil
}
}
return nil, nil
}
func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[l.Name] = l
return nil
}
func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder {
return false, nil
}
m.leases[key] = struct {
holder string
until time.Time
}{holder, m.now().Add(d)}
return true, nil
}
func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.leases[key].holder == holder {
delete(m.leases, key)
}
return nil
}
func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Binding, 0, len(m.binds))
for _, b := range m.binds {
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
if b, ok := m.binds[consumer]; ok {
return &b, nil
}
return nil, nil
}
func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.generation++
b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation}
m.binds[consumer] = b
return b, nil
}
func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.binds[consumer]
delete(m.binds, consumer)
return ok, nil
}
func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.generation = max(m.generation, generation)
return nil
}
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []Binding
for c, b := range m.binds {
if b.Licence != licence {
continue
}
m.generation++
b.Generation = m.generation
m.binds[c] = b
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.outcomes[fp], nil
}
func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.outcomes[fp] = o
return nil
}
func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r})
return nil
}
func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []UsageRow
for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- {
if licence == "" || m.usage[i].Licence == licence {
out = append(out, m.usage[i])
}
}
return out, nil
}
func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
d := map[string]any{"what": what}
for k, v := range detail {
d[k] = v
}
m.Audits = append(m.Audits, d)
return nil
}
func (m *MemoryStore) Close() {}
@@ -1,126 +0,0 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
)
// Against a real postgres, because the questions are the database's: does the schema apply twice, does a
// lease refuse a second holder, does a generation only grow. Skipped unless one is named:
//
// docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
// MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
func TestTheStoreOnPostgres(t *testing.T) {
url := os.Getenv("MESH_TEST_POSTGRES")
if url == "" {
t.Skip("MESH_TEST_POSTGRES unset")
}
ctx := context.Background()
s, err := OpenPgStore(ctx, url)
if err != nil {
t.Fatal(err)
}
defer s.Close()
for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} {
_, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade")
}
_, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation")
for i := 0; i < 2; i++ {
if err := s.Migrate(ctx); err != nil {
t.Fatalf("migration %d: %v", i+1, err)
}
}
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y",
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
if err := s.SaveLicence(ctx, l); err != nil {
t.Fatal(err)
}
l.Failures = 2
_ = s.SaveLicence(ctx, l)
if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" {
t.Fatalf("%+v", got)
}
if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil {
t.Fatalf("%v %v", none, err)
}
lease := func(holder string) bool {
ok, err := s.Lease(ctx, "licence:a", holder, time.Minute)
if err != nil {
t.Fatal(err)
}
return ok
}
if !lease("one") || lease("two") || !lease("one") {
t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it")
}
_ = s.Unlease(ctx, "licence:a", "one")
if !lease("two") {
t.Fatal("a released lease was not taken")
}
b1, _ := s.Bind(ctx, "laptop", l.Name)
adv, _ := s.Advance(ctx, l.Name)
b3, _ := s.Bind(ctx, "laptop", l.Name)
if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) {
t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation)
}
_ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400")
if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead {
t.Fatalf("outcome %q", o)
}
if o, _ := s.Outcome(ctx, "sha256:none"); o != "" {
t.Fatalf("an unknown login is %q", o)
}
pct := 1.0
_ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{})
if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 {
t.Fatalf("usage %v", u)
}
if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil {
t.Fatal(err)
}
if ok, _ := s.Unbind(ctx, "laptop"); !ok {
t.Fatal("unbind")
}
all, _ := s.Licences(ctx)
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") {
t.Fatalf("%v", all)
}
}
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
in := int64(60)
kept := NextGrant(prev, tokenResponse{AccessToken: "a2", ExpiresIn: &in}, 1000)
if !kept.OK || kept.Grant.RefreshToken != "r" || kept.Grant.ExpiresAt != 61_000 {
t.Fatalf("%+v", kept)
}
rotated := NextGrant(prev, tokenResponse{AccessToken: "a3", RefreshToken: "r2"}, 0)
if rotated.Grant.RefreshToken != "r2" {
t.Fatalf("%+v", rotated)
}
if NextGrant(prev, tokenResponse{}, 0).OK {
t.Fatal("an answer with no access token was a grant")
}
}
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
a, _ := NewCrypt("one key")
b, _ := NewCrypt("another key")
sealed := a.Seal(`{"refreshToken":"rt"}`)
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
if strings.Contains(sealed, "refreshToken") {
t.Fatal("stored in the clear")
}
if got, err := a.Open(sealed); err != nil || got != `{"refreshToken":"rt"}` {
t.Fatalf("%q %v", got, err)
}
if _, err := b.Open(sealed); err == nil {
t.Fatal("opened with another key")
}
if _, err := NewCrypt(" "); err == nil {
t.Fatal("an empty key was accepted")
}
}
@@ -0,0 +1,321 @@
package main
// The manager's kept data on the bus (design 39 bus-and-switch §2, §3, B11, B22): the state `licences`, one key per
// licence by its key form (§10), and the state `bindings`, one key per consumer, its value the form the consumers
// have always read. Written by the manager alone.
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
. "claude-licence-manager/internal/licences"
)
// BusStore keeps the manager's data in the bus's own storage.
type BusStore struct {
LicencesState State
BindingsState State
LeasesState State
OfferedState State
UsageLog Log
AuditLog Log
Now func() time.Time
}
func (s *BusStore) get(st State, key string, into any) (bool, error) {
e, err := st.Get(key)
if err != nil || e == nil || len(e.Value) == 0 || string(e.Value) == "null" {
return false, err
}
return true, json.Unmarshal(e.Value, into)
}
func (s *BusStore) Licences(context.Context) ([]Licence, error) {
keys, err := s.LicencesState.Keys()
if err != nil {
return nil, err
}
out := []Licence{}
for _, k := range keys {
var l Licence
if ok, err := s.get(s.LicencesState, k, &l); err != nil {
return nil, err
} else if ok {
out = append(out, l)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (s *BusStore) Licence(_ context.Context, name string) (*Licence, error) {
var l Licence
ok, err := s.get(s.LicencesState, KeyFor(name), &l)
if err != nil || !ok || l.Name != name {
return nil, err
}
return &l, nil
}
func (s *BusStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
all, err := s.Licences(ctx)
if err != nil {
return nil, err
}
for _, l := range all {
if l.AccountUUID == account {
return &l, nil
}
}
return nil, nil
}
func (s *BusStore) SaveLicence(_ context.Context, l Licence) error {
if l.Name == "" {
return errors.New("a licence has a name")
}
_, err := s.LicencesState.Put(KeyFor(l.Name), l)
return err
}
func (s *BusStore) Bindings(context.Context) ([]Binding, error) {
keys, err := s.BindingsState.Keys()
if err != nil {
return nil, err
}
out := []Binding{}
for _, k := range keys {
if b, err := s.binding(k); err != nil {
return nil, err
} else if b != nil {
out = append(out, *b)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (s *BusStore) binding(consumer string) (*Binding, error) {
var v BindingState
ok, err := s.get(s.BindingsState, consumer, &v)
if err != nil || !ok {
return nil, err
}
return &Binding{Consumer: consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Since: v.Since,
Why: v.Why, By: v.By}, nil
}
func (s *BusStore) Binding(_ context.Context, consumer string) (*Binding, error) {
return s.binding(consumer)
}
// Bind writes a consumer's key at its next generation: one more than the higher of its own and the floor (B11).
func (s *BusStore) Bind(_ context.Context, consumer, licence string, m Move) (Binding, error) {
before, err := s.binding(consumer)
if err != nil {
return Binding{}, err
}
generation := m.floor(consumer)
if before != nil {
generation = max(generation, before.Generation)
}
b := Binding{Consumer: consumer, Licence: licence, Kind: m.Kind, Generation: generation + 1,
Since: s.Now().UTC().Format(time.RFC3339), Why: m.Why, By: m.By}
_, err = s.BindingsState.Put(consumer, BindingState{Licence: b.Licence, Kind: b.Kind, Generation: b.Generation,
Since: b.Since, Why: b.Why, By: b.By})
return b, err
}
func (s *BusStore) Unbind(_ context.Context, consumer string) (bool, error) {
before, err := s.binding(consumer)
if err != nil || before == nil {
return false, err
}
return true, s.BindingsState.Delete(consumer)
}
// Advance gives every consumer bound to licence its next generation, keeping its licence and since.
func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Binding, error) {
all, err := s.Bindings(ctx)
if err != nil {
return nil, err
}
var out []Binding
for _, b := range all {
if b.Licence != licence {
continue
}
b.Generation = max(b.Generation, m.floor(b.Consumer)) + 1
b.Kind, b.Why, b.By = m.Kind, m.Why, m.By
if _, err := s.BindingsState.Put(b.Consumer, BindingState{Licence: b.Licence, Kind: b.Kind,
Generation: b.Generation, Since: b.Since, Why: b.Why, By: b.By}); err != nil {
return out, err
}
out = append(out, b)
}
return out, nil
}
// ---- leases and offers (§4, §5) ---------------------------------------------------------------------------------
// Lease takes a lease by creating its key with a lifetime of d (§4): a create fails while another holds it, the bus's
// own compare-and-set. A key its own holder holds is taken again.
func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
v := LeaseValue{Holder: holder, Until: s.Now().Add(d).UTC().Format(time.RFC3339)}
_, err := s.LeasesState.CreateTTL(key, v, d)
if err == nil {
return true, nil
}
if !errors.Is(err, stdio.ErrChanged) {
return false, err
}
var held LeaseValue
if ok, gerr := s.get(s.LeasesState, key, &held); gerr != nil || !ok || held.Holder != holder {
return false, gerr
}
_, err = s.LeasesState.PutTTL(key, v, d)
return err == nil, err
}
// Unlease deletes a lease its holder holds; another's is left to it or its lifetime.
func (s *BusStore) Unlease(_ context.Context, key, holder string) error {
var held LeaseValue
if ok, err := s.get(s.LeasesState, key, &held); err != nil || !ok || held.Holder != holder {
return err
}
return s.LeasesState.Delete(key)
}
// offeredFor is how long the outcome of an offered grant is kept (§5): longer than a refresh token lives.
const offeredFor = 90 * 24 * time.Hour
// OfferedKey is a fingerprint's key in `offered`: through the licence key form (B13).
func OfferedKey(fingerprint string) string { return KeyFor(fingerprint) }
func (s *BusStore) Outcome(_ context.Context, fingerprint string) (Outcome, error) {
var v OfferedValue
if ok, err := s.get(s.OfferedState, OfferedKey(fingerprint), &v); err != nil || !ok {
return "", err
}
return v.Outcome, nil
}
func (s *BusStore) RecordOutcome(_ context.Context, fingerprint, node, account string, o Outcome, why string) error {
return s.putOffered(fingerprint, OfferedValue{Node: node, Account: account, Outcome: o, Why: why,
At: s.Now().UTC().Format(time.RFC3339)}, offeredFor)
}
func (s *BusStore) putOffered(fingerprint string, v OfferedValue, ttl time.Duration) error {
_, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl)
return err
}
// ---- the logs (§6, §7) ------------------------------------------------------------------------------------------
func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
_, err := s.UsageLog.Append(KeyFor(licence), UsageEntry{At: time.UnixMilli(at).UTC().Format(time.RFC3339Nano),
Reading: r, Raw: raw})
return err
}
// Usage is the latest readings, newest first, of one licence or every one, none before since, the vendor's answer
// with raw (§35).
func (s *BusStore) Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error) {
names := map[string]string{}
all, err := s.Licences(ctx)
if err != nil {
return nil, err
}
for _, l := range all {
names[KeyFor(l.Name)] = l.Name
}
keys := ""
if licence != "" {
keys = KeyFor(licence)
}
entries, err := s.newest(s.UsageLog, keys, limit)
if err != nil {
return nil, err
}
out := []UsageRow{}
for _, e := range entries {
var u UsageEntry
if json.Unmarshal(e.Value, &u) != nil {
continue
}
at, _ := time.Parse(time.RFC3339Nano, u.At)
if at.Before(since) {
continue
}
name := names[e.Key]
if name == "" {
name = e.Key
}
row := UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading}
if raw {
row.Raw = u.Raw
}
out = append(out, row)
}
return out, nil
}
// newest is up to limit entries of keys, newest first: read back from the log's last sequence, a window that doubles
// until it holds limit entries or reaches the first.
func (s *BusStore) newest(l Log, keys string, limit int) ([]stdio.LogEntry, error) {
info, err := l.Info()
if err != nil || info.Last == 0 {
return nil, err
}
for span := uint64(max(limit, 1)); ; span *= 2 {
from := uint64(1)
if info.Last > span {
from = info.Last - span + 1
}
var got []stdio.LogEntry
for next := from; next != 0; {
page, err := l.Read(next, keys, 0)
if err != nil {
return nil, err
}
got = append(got, page.Entries...)
next = page.Next
}
if len(got) >= limit || from <= max(info.First, 1) {
if len(got) > limit {
got = got[len(got)-limit:]
}
for i, j := 0, len(got)-1; i < j; i, j = i+1, j-1 {
got[i], got[j] = got[j], got[i]
}
return got, nil
}
}
}
// Audit appends one entry under its kind, with when; a log that does not take it fails the act (B20).
func (s *BusStore) Audit(_ context.Context, what string, detail map[string]any) error {
kind := "drift"
for _, k := range AuditKinds {
if k == what {
kind = k
}
}
entry := map[string]any{"at": s.Now().UTC().Format(time.RFC3339Nano)}
if kind != what {
entry["what"] = what
}
for k, v := range detail {
entry[k] = v
}
if _, err := s.AuditLog.Append(kind, entry); err != nil {
return fmt.Errorf("the audit did not take the %s entry, so the act is not done: %w", what, err)
}
return nil
}
@@ -0,0 +1,361 @@
package main
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
. "claude-licence-manager/internal/licences"
)
// memState is a state of the bus as the runtime answers it, in memory: values, their past values, keys' lifetimes.
type memState struct {
mu sync.Mutex
now func() time.Time
values map[string]json.RawMessage
until map[string]time.Time
history map[string][]stdio.StateRevision
rev uint64
writes int
}
func newMemState(now func() time.Time) *memState {
return &memState{now: now, values: map[string]json.RawMessage{}, until: map[string]time.Time{},
history: map[string][]stdio.StateRevision{}}
}
func (s *memState) live(key string) bool {
if u, ok := s.until[key]; ok && !s.now().Before(u) {
delete(s.values, key)
delete(s.until, key)
}
_, ok := s.values[key]
return ok
}
func (s *memState) Get(key string) (*stdio.StateEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.live(key) {
return nil, nil
}
return &stdio.StateEntry{Key: key, Value: s.values[key]}, nil
}
func (s *memState) write(key string, value any, ttl time.Duration, create bool) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if create && s.live(key) {
return 0, stdio.ErrChanged
}
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
s.rev++
s.writes++
s.values[key] = raw
delete(s.until, key)
if ttl > 0 {
s.until[key] = s.now().Add(ttl)
}
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Value: raw, Revision: s.rev, Op: "put", At: s.now()})
return s.rev, nil
}
func (s *memState) Put(key string, value any) (uint64, error) { return s.write(key, value, 0, false) }
func (s *memState) PutTTL(key string, value any, ttl time.Duration) (uint64, error) {
return s.write(key, value, ttl, false)
}
func (s *memState) Create(key string, value any) (uint64, error) { return s.write(key, value, 0, true) }
func (s *memState) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) {
return s.write(key, value, ttl, true)
}
func (s *memState) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.rev++
s.writes++
delete(s.values, key)
delete(s.until, key)
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Revision: s.rev, Op: "delete", At: s.now()})
return nil
}
func (s *memState) Keys() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := []string{}
for k := range s.values {
if s.live(k) {
out = append(out, k)
}
}
sort.Strings(out)
return out, nil
}
func (s *memState) History(key string) ([]stdio.StateRevision, error) {
s.mu.Lock()
defer s.mu.Unlock()
return append([]stdio.StateRevision(nil), s.history[key]...), nil
}
// design 39 bus-and-switch §2: a rotation and a reading each write the licence's key, by its key form, and no field
// holds a token.
func TestARotationAndAReadingWriteTheLicenceKey(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
key := KeyFor(licence1)
before := len(mm.licences.history[key])
if _, err := mm.m.Rotate(ctx, licence1, true); err != nil {
t.Fatal(err)
}
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
if got := len(mm.licences.history[key]); got < before+2 {
t.Fatalf("the key %s was written %d times by a rotation and a reading, want 2", key, got-before)
}
for _, r := range mm.licences.history[key] {
for _, token := range []string{"rt-", "at-"} {
if strings.Contains(string(r.Value), token) {
t.Fatalf("a value of %s holds %s: %s", key, token, r.Value)
}
}
}
var l Licence
_ = json.Unmarshal(mm.licences.values[key], &l)
if l.Name != licence1 || l.AccountUUID != "acct-1" || !strings.Contains(string(mm.licences.values[key]), `"account":"acct-1"`) {
t.Fatalf("the key holds %+v", l)
}
}
// design 39 bus-and-switch §3, B11: bind, switch, release and a rotation each write or delete the consumer's key with
// its why and by; each consumer counts its own generations, past what its node reported.
func TestEachMoveWritesTheBindingWithWhyAndBy(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
mm.m.Reported = func(node string) int64 {
if node == "server" {
return 40
}
return 0
}
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "jane"})
if _, err := mm.m.Bind(asked, "server", "api"); err != nil {
t.Fatal(err)
}
if b := mm.bound("server"); b.Licence != "api" || b.Generation != 41 || b.Why != "the switch verb" || b.By != "jane" ||
b.Since == "" || b.Kind != "api-key" {
t.Fatalf("server's binding %+v", b)
}
laptop := mm.bound("laptop").Generation
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if b := mm.bound("laptop"); b.Generation != laptop+1 || b.Licence != "api" {
t.Fatalf("laptop's switch %+v, from generation %d", b, laptop)
}
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "jane"}), "server"); err != nil {
t.Fatal(err)
}
if _, ok := mm.bindings.values["server"]; ok {
t.Fatal("release left the key")
}
if h, _ := mm.bindings.History("server"); len(h) != 2 || h[1].Op != "delete" {
t.Fatalf("server's history %v", h)
}
}
// design 39 bus-and-switch §4, §5, B13: a lease is a key created with a two-minute lifetime that another holder cannot
// create; an offered grant's outcome is kept under its fingerprint in the key form for 90 days.
func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
key := "licence." + KeyFor(licence1)
if ok, err := mm.store.Lease(ctx, key, "one", leaseFor); !ok || err != nil {
t.Fatalf("the first lease: %v %v", ok, err)
}
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); ok {
t.Fatal("a second holder took a live lease")
}
if u := mm.leases.until[key]; !u.Equal(mm.now.Add(2 * time.Minute)) {
t.Fatalf("the lease lives until %v", u)
}
mm.now = mm.now.Add(2 * time.Minute)
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); !ok {
t.Fatal("a lease past its lifetime was not taken")
}
_ = mm.store.Unlease(ctx, key, "one")
if _, held := mm.leases.values[key]; !held {
t.Fatal("another holder's lease was ended")
}
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-dead", false, t0)})
fp := OfferedKey(Fingerprint("rt-dead"))
if strings.ContainsAny(fp, ":") || !strings.HasPrefix(fp, "sha256_") {
t.Fatalf("the offered key %q is not in the key form", fp)
}
var v OfferedValue
_ = json.Unmarshal(mm.offered.values[fp], &v)
if v.Outcome != Dead || v.Node != "laptop" || v.Account != "acct-1" || v.Why == "" || v.At == "" {
t.Fatalf("offered %+v", v)
}
if u := mm.offered.until[fp]; !u.Equal(mm.now.Add(90 * 24 * time.Hour)) {
t.Fatalf("the outcome lives until %v", u)
}
}
// memLog is a log of the bus as the runtime answers it, in memory; full, it refuses an entry.
type memLog struct {
mu sync.Mutex
now func() time.Time
entries []stdio.LogEntry
full bool
}
func newMemLog(now func() time.Time) *memLog { return &memLog{now: now} }
func (l *memLog) Append(key string, value any) (uint64, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.full {
return 0, errors.New("the log is full: maximum bytes exceeded")
}
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
seq := uint64(len(l.entries) + 1)
l.entries = append(l.entries, stdio.LogEntry{Sequence: seq, Key: key, Value: raw, At: l.now()})
return seq, nil
}
func (l *memLog) Read(from uint64, keys string, limit int) (*stdio.LogPage, error) {
l.mu.Lock()
defer l.mu.Unlock()
p := &stdio.LogPage{Entries: []stdio.LogEntry{}, Last: uint64(len(l.entries))}
for _, e := range l.entries {
if e.Sequence >= from && (keys == "" || e.Key == keys) {
if limit > 0 && len(p.Entries) == limit {
p.Next = e.Sequence
break
}
p.Entries = append(p.Entries, e)
}
}
return p, nil
}
func (l *memLog) Last(key string) (*stdio.LogEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
for i := len(l.entries) - 1; i >= 0; i-- {
if l.entries[i].Key == key {
e := l.entries[i]
return &e, nil
}
}
return nil, nil
}
func (l *memLog) Info() (*stdio.LogInfo, error) {
l.mu.Lock()
defer l.mu.Unlock()
i := &stdio.LogInfo{Entries: uint64(len(l.entries)), Last: uint64(len(l.entries))}
if len(l.entries) > 0 {
i.First = 1
}
return i, nil
}
func (l *memLog) Newest(keys string) ([]stdio.LogEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
last := map[string]int{}
for i, e := range l.entries {
if keys == "" || e.Key == keys {
last[e.Key] = i
}
}
out := []stdio.LogEntry{}
for i, e := range l.entries {
if j, ok := last[e.Key]; ok && j == i {
out = append(out, e)
}
}
return out, nil
}
// under is every entry of a log's subject, decoded.
func (l *memLog) under(key string) []map[string]any {
l.mu.Lock()
defer l.mu.Unlock()
var out []map[string]any
for _, e := range l.entries {
if key == "" || e.Key == key {
var v map[string]any
_ = json.Unmarshal(e.Value, &v)
v["what"] = e.Key
out = append(out, v)
}
}
return out
}
// design 39 bus-and-switch §6: a reading appends one entry under its licence's key, the reading beside the raw answer.
func TestAReadingAppendsOneUsageEntry(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
got := mm.usage.under(KeyFor(licence1))
if len(got) != 1 || len(mm.usage.entries) != 1 {
t.Fatalf("a reading appended %v", mm.usage.entries)
}
if got[0]["raw"] == nil || got[0]["reading"] == nil || got[0]["at"] == nil {
t.Fatalf("the entry %v", got[0])
}
rows, _ := mm.store.Usage(ctx, licence1, time.Time{}, 20, false)
if len(rows) != 1 || rows[0].Licence != licence1 || rows[0].Reading.SessionPct == nil {
t.Fatalf("usage answers %+v", rows)
}
}
// design 39 bus-and-switch §7, B20: each act appends one entry under its kind; a full log fails the act loudly.
func TestEachActAppendsOneAuditEntry(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_, _ = mm.m.Rotate(ctx, licence1, true)
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
_, _ = mm.m.Bind(ctx, "laptop", "api")
_, _ = mm.m.Release(ctx, "laptop")
for kind, n := range map[string]int{"adopted": 1, "bound": 1, "rotated": 1, "switched": 1, "released": 1} {
if got := len(mm.audit.under(kind)); got != n {
t.Errorf("%s: %d entries, want %d", kind, got, n)
}
}
for _, e := range mm.audit.under("") {
if e["at"] == nil || e["by"] == nil && e["what"] != "adopted" {
t.Errorf("the entry %v has no at or by", e)
}
}
mm.audit.full = true
if _, err := mm.m.Bind(ctx, "laptop", "api"); err == nil || !strings.Contains(err.Error(), "the audit did not take") {
t.Fatalf("a bind with a full audit log: %v", err)
}
}
@@ -0,0 +1,302 @@
package main
// The manager's conditions (design 39 bus-and-switch §9, §11, §29 to §31, §33, B17, B37 to B39): each said on the
// operator channel as any module says one (novox/hq ADR 0437 §9), the router alone deciding whether and where the
// operator is told, and each it said raised kept in its state `conditions`, written before it is said and deleted when
// it is said cleared. A raise or a clear is said again only when what it says changes (ADR 0289 §1); at its start the
// manager judges every kind anew, says raised again each it still holds and cleared each key it no longer holds.
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
. "claude-licence-manager/internal/licences"
)
// HeadlineLength is the operator channel's limit on a headline; a longer one is cut, ending in "…" (B39).
const HeadlineLength = 60
// SayCondition publishes a condition on the operator channel's condition accept, under the manager's name, in the form
// its client encodes (ADR 0437 §9).
func SayCondition(c oc.Condition) error {
subject, err := occ.ConditionSubject(Publisher)
if err != nil {
return err
}
raw, err := occ.EncodeCondition(c)
if err != nil {
return err
}
return stdio.SeatPublish(subject, json.RawMessage(raw), "")
}
// condition is a raised condition of the manager: its key, severity, words and the line said when it is over. An
// empty needs is "Nothing for you to do."; the explanation is opened by its verdict here.
func condition(key, severity, headline, summary, needs, explanation, resolved string) oc.Condition {
verdict := occ.NothingToDo
if needs != "" {
verdict = occ.NeedsYou + needs + "."
}
if explanation != "" {
verdict += " " + explanation
}
return oc.Condition{Key: key, Severity: severity, State: occ.StateRaised,
Subject: oc.ConditionSubject{Scope: "module", ID: Publisher},
Headline: cut(headline), Summary: summary, Needs: needs, Explanation: verdict, Resolved: resolved}
}
// cut keeps a headline within the operator channel's limit, ending a longer one in "…" (B39).
func cut(headline string) string {
r := []rune(headline)
if len(r) <= HeadlineLength {
return headline
}
return string(r[:HeadlineLength-1]) + "…"
}
// short is a licence's short name for a headline: its address before the "@", or its name (B39).
func short(name string) string {
if at := strings.Index(name, "@"); at > 0 {
return name[:at]
}
return name
}
// machines is a count of consumers in a headline (B39).
func machines(n int) string {
if n == 1 {
return "1 machine"
}
return fmt.Sprintf("%d machines", n)
}
// Judge is every condition the manager holds now, judged anew from its own state and readings, by key.
func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) {
held := map[string]oc.Condition{}
s := m.settings()
reasons := s.Reasons()
for _, o := range s.OutOfBound {
bound := fmt.Sprintf("at least %g", o.Min)
if o.HasMax {
bound = fmt.Sprintf("between %g and %g", o.Min, o.Max)
}
key := "settings." + o.Setting + ".invalid"
held[key] = condition(key, occ.SeverityWarning, o.Setting+" is outside its bound",
reasons[o.Setting],
fmt.Sprintf("set %s %s", o.Setting, bound),
fmt.Sprintf("%s is set to %g; the licence manager uses its default %g until it is fixed.", o.Setting,
o.Value, o.Default), o.Setting+" is within its bound again")
}
for _, p := range s.Invalid() {
key := "settings." + p.Setting + ".invalid"
if _, ok := held[key]; ok {
continue
}
held[key] = condition(key, occ.SeverityWarning, p.Setting+" is above switch_at_pct",
reasons[p.Setting],
fmt.Sprintf("set %s at or below switch_at_pct (%g)", p.Setting, p.SwitchAt),
fmt.Sprintf("%s is %g, above switch_at_pct %g; the licence manager moves no consumer until it is fixed.",
p.Setting, p.Value, p.SwitchAt), p.Setting+" is at or below switch_at_pct again")
}
licences, err := m.Store.Licences(ctx)
if err != nil {
return nil, err
}
bindings, err := m.Store.Bindings(ctx)
if err != nil {
return nil, err
}
for _, l := range licences {
for key, c := range m.judgeLicence(l, licences, bindings, s) {
held[key] = c
}
}
return held, nil
}
// judgeLicence is what licence l holds of no-room (§29), near-limit (§30) and usage-unread (§31).
func (m *Manager) judgeLicence(l Licence, licences []Licence, bindings []Binding, s Settings) map[string]oc.Condition {
held := map[string]oc.Condition{}
key := "licence." + KeyFor(l.Name) + "."
consumers := consumersOf(l.Name, bindings)
list := strings.Join(consumers, ", ")
if r := m.RuleFor(l, licences, bindings, s); r.NoTarget {
o := r.Over
var rooms []string
for _, other := range licences {
if other.Name != l.Name {
rooms = append(rooms, other.Name+" "+m.roomWords(other, s))
}
}
sort.Strings(rooms)
others := "no other licence is held"
if len(rooms) > 0 {
others = strings.Join(rooms, "; ")
}
held[key+"no-room"] = condition(key+"no-room", occ.SeverityUrgent,
"No licence has room for "+machines(len(consumers)),
fmt.Sprintf("no licence has room for %s, bound to %s at %g %% of its %s window", list, l.Name, o.Pct, o.Window),
fmt.Sprintf("add a licence, or wait for %s's %s window to reset at %s", l.Name, o.Window, o.ResetsAt),
fmt.Sprintf("%s is at %g %% of its %s window, with %s bound to it; %s.", l.Name, o.Pct, o.Window, list, others),
list+" have a licence with room again")
}
if window, pct, resetsAt, ok := windowAt(l.Usage, s.WarnAt); ok && len(consumers) > 0 {
needs, plan := "", ""
if to, _ := m.targetFor(l, licences, s); to != "" {
plan = fmt.Sprintf("%s move to %s at %g %%. ", list, to, s.SwitchAt)
} else {
needs = "no other licence has room"
}
held[key+"near-limit"] = condition(key+"near-limit", occ.SeverityWarning,
fmt.Sprintf("%s at %g%% %s", short(l.Name), pct, window),
fmt.Sprintf("%s at %g %% of its %s window", l.Name, pct, window), needs,
fmt.Sprintf("%s%s is at %g %% of its %s window, which resets at %s; %s bound to it.", plan, l.Name, pct,
window, resetsAt, list), fmt.Sprintf("%s is below %g %% again", l.Name, s.WarnAt))
}
if since, stale := m.unread(l, s); stale {
needs := ""
if len(consumers) > 0 {
needs = list + " are not moved off it while its usage is unknown"
}
why := l.Usage.ReadErrorWords()
held[key+"usage-unread"] = condition(key+"usage-unread", occ.SeverityWarning,
short(l.Name)+"'s usage cannot be read",
fmt.Sprintf("the usage of %s cannot be read: %s", l.Name, why), needs,
fmt.Sprintf("%s, older than %g minutes; the vendor answers: %s.", since, s.UsageStale.Minutes(), why),
l.Name+"'s usage is read again")
}
return held
}
// unread is whether subscription l's latest successful reading is older than usage_stale_minutes (§31), or, never
// read successfully, its adoption is (B38); and the words naming since when.
func (m *Manager) unread(l Licence, s Settings) (string, bool) {
if l.Kind != "subscription" {
return "", false
}
if l.Usage != nil && l.Usage.ReadAt != "" {
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
if err != nil {
return "its latest successful reading has no time", true
}
return "its latest successful reading was at " + l.Usage.ReadAt, m.Now().Sub(read) > s.UsageStale
}
if l.AdoptedAt == 0 {
return "", false
}
adopted := time.UnixMilli(l.AdoptedAt)
return "no reading has succeeded since its adoption at " + adopted.UTC().Format(time.RFC3339),
m.Now().Sub(adopted) > s.UsageStale
}
// roomWords is another licence's room for the condition no-room, or why it is not known or not enough (§29).
func (m *Manager) roomWords(o Licence, s Settings) string {
room, known := Room(o, m.Now(), s)
switch {
case known && *o.Usage.WeeklyPct >= s.RoomBelow:
return fmt.Sprintf("has %g %% room, its weekly window not below %g %%", room, s.RoomBelow)
case known:
return fmt.Sprintf("has %g %% room", room)
case o.Kind != "subscription":
return "is an API key, whose room is never known"
case o.Usage == nil || o.Usage.ReadAt == "":
return "has no reading"
case o.Usage.WeeklyPct == nil:
return "has no weekly window in its reading"
case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()):
return "does not refresh"
}
return fmt.Sprintf("was not read within %g minutes", s.UsageStale.Minutes())
}
// SayConditions judges every kind anew and says what changed: each condition held whose words differ from those kept
// is written in `conditions`, then said raised; each kept key no longer held is said cleared, then deleted. With
// again (at the start, and by licences_resync), each held is said raised again whether or not it changed.
func (m *Manager) SayConditions(ctx context.Context, again bool) error {
if m.Conditions == nil || m.Say == nil {
return nil
}
m.conditionsMu.Lock()
defer m.conditionsMu.Unlock()
held, err := m.Judge(ctx)
if err != nil {
return err
}
keys, err := m.Conditions.Keys()
if err != nil {
return err
}
var errs []error
for _, key := range keys {
if _, ok := held[key]; ok {
continue
}
var kept KeptCondition
e, err := m.Conditions.Get(key)
if err != nil {
errs = append(errs, err)
continue
}
if e != nil && len(e.Value) > 0 {
_ = json.Unmarshal(e.Value, &kept)
}
cleared := kept.Body
cleared.Key, cleared.State = key, occ.StateCleared
cleared.Subject = oc.ConditionSubject{Scope: "module", ID: Publisher}
if err := m.Say(cleared); err != nil {
errs = append(errs, fmt.Errorf("the clearing of %s could not be said: %w", key, err))
continue
}
if err := m.Conditions.Delete(key); err != nil {
errs = append(errs, err)
}
}
var order []string
for key := range held {
order = append(order, key)
}
sort.Strings(order)
for _, key := range order {
c := held[key]
kept := KeptCondition{Body: c, Since: m.Now().UTC().Format(time.RFC3339)}
if e, err := m.Conditions.Get(key); err == nil && e != nil && len(e.Value) > 0 {
var was KeptCondition
if json.Unmarshal(e.Value, &was) == nil {
if !again && sameCondition(was.Body, c) {
continue
}
if was.Since != "" {
kept.Since = was.Since
}
}
}
if _, err := m.Conditions.Put(key, kept); err != nil {
errs = append(errs, err)
continue
}
if err := m.Say(c); err != nil {
// Not said: the key goes again, so the next judgement says it.
_ = m.Conditions.Delete(key)
errs = append(errs, fmt.Errorf("%s could not be said: %w", key, err))
}
}
if len(errs) > 0 {
return fmt.Errorf("conditions: %v", errs)
}
return nil
}
// sameCondition is whether two raised conditions say the same.
func sameCondition(a, b oc.Condition) bool {
ra, _ := json.Marshal(a)
rb, _ := json.Marshal(b)
return string(ra) == string(rb)
}
@@ -0,0 +1,251 @@
package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
. "claude-licence-manager/internal/licences"
)
// saying gives mm's manager a state `conditions` and records each condition it says, in the form the operator
// channel's client encodes, refusing what it refuses.
func saying(mm *miniMesh) (*memState, *[]oc.Condition) {
kept := newMemState(func() time.Time { return mm.now })
said := &[]oc.Condition{}
mm.m.Conditions = kept
mm.m.Say = func(c oc.Condition) error {
if _, err := occ.EncodeCondition(c); err != nil {
return err
}
if _, err := occ.ConditionSubject(Publisher); err != nil {
return err
}
*said = append(*said, c)
return nil
}
return kept, said
}
// of is what was said of key, as "state" per saying.
func of(said []oc.Condition, key string) []string {
var out []string
for _, c := range said {
if c.Key == key {
out = append(out, c.State)
}
}
return out
}
// design 39 bus-and-switch §9/AC1, §11, §33, B17, B37: each kind of condition the manager holds of itself is written in
// `conditions` with its body and since, said once with its key, severity, scope and id, its words opened by their
// verdict, and said cleared, its key deleted, once it no longer holds.
func TestAConditionIsKeptThenSaidOnce(t *testing.T) {
ctx := context.Background()
file := filepath.Join(t.TempDir(), "settings.json")
for _, c := range []struct {
name, key, severity string
raise, fix func(mm *miniMesh)
}{
{"an invalid pair", "settings.room_below_pct.invalid", occ.SeverityWarning,
func(mm *miniMesh) { mm.m.Settings.RoomBelow = 100 }, func(mm *miniMesh) { mm.m.Settings.RoomBelow = 90 }},
{"a setting outside its bound", "settings.switch_at_pct.invalid", occ.SeverityWarning,
func(mm *miniMesh) {
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 150}`), 0o600)
mm.m.Settings = SettingsFrom(file)
},
func(mm *miniMesh) {
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 98}`), 0o600)
mm.m.Settings = SettingsFrom(file)
}},
} {
mm := newMesh(t)
kept, said := saying(mm)
c.raise(mm)
for range 2 {
if err := mm.m.SayConditions(ctx, false); err != nil {
t.Fatalf("%s: %v", c.name, err)
}
}
if got := of(*said, c.key); strings.Join(got, ",") != "raised" {
t.Fatalf("%s: said %v of %s, all said %+v", c.name, got, c.key, *said)
}
s := (*said)[0]
if s.Severity != c.severity || s.Subject.Scope != "module" || s.Subject.ID != Publisher || s.Headline == "" ||
s.Summary == "" || s.Resolved == "" || !strings.HasPrefix(s.Explanation, occ.NeedsYou+s.Needs) || s.Needs == "" {
t.Fatalf("%s: said %+v", c.name, s)
}
var k KeptCondition
if e, _ := kept.Get(c.key); e == nil || json.Unmarshal(e.Value, &k) != nil || k.Body.Key != c.key || k.Since == "" {
t.Fatalf("%s: kept %+v", c.name, k)
}
c.fix(mm)
_ = mm.m.SayConditions(ctx, false)
if got := of(*said, c.key); strings.Join(got, ",") != "raised,cleared" {
t.Fatalf("%s: said %v", c.name, got)
}
if e, _ := kept.Get(c.key); e != nil {
t.Fatalf("%s: the key is kept after its clearing", c.name)
}
}
}
// design 39 bus-and-switch §9/AC2, design 39 parts §11: at its start the manager says cleared each key of `conditions` it no longer holds,
// and says raised again each it still holds, unchanged or not.
func TestAStartClearsWhatItNoLongerHolds(t *testing.T) {
ctx := context.Background()
mm := newMesh(t)
kept, said := saying(mm)
gone := condition("licence.gone.no-room", occ.SeverityUrgent, "No licence has room for 1 machine", "s", "add one",
"", "x has a licence with room again")
mm.m.Settings.WarnAt = 100
_ = mm.m.SayConditions(ctx, false)
_, _ = kept.Put(gone.Key, KeptCondition{Body: gone, Since: "2026-11-11T20:00:00Z"})
// design 39 parts §11: a carry-over-failed key kept from before the carry-over left is cleared like any other.
carry := condition("carry-over-failed", occ.SeverityUrgent, "The licence manager could not carry over its licences",
"s", "", "", "The licence manager carried over its licences")
_, _ = kept.Put(carry.Key, KeptCondition{Body: carry, Since: "2026-11-12T09:00:00Z"})
*said = nil
if err := mm.m.SayConditions(ctx, true); err != nil {
t.Fatal(err)
}
if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, carry.Key), ",") != "cleared" ||
strings.Join(of(*said, "settings.warn_at_pct.invalid"), ",") != "raised" {
t.Fatalf("a start said %+v", *said)
}
if (*said)[0].Resolved != carry.Resolved || (*said)[1].Resolved != gone.Resolved {
t.Fatalf("the clearings carry %+v", *said)
}
for _, key := range []string{gone.Key, carry.Key} {
if e, _ := kept.Get(key); e != nil {
t.Fatalf("%s, no longer held, is kept", key)
}
}
}
// design 39 bus-and-switch §29/AC1, B39: with every licence at its threshold, no-room is said once, its headline a
// count of machines, its words naming the licence, the window's reset and every other licence's room; a reading with
// room clears it.
func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
ctx := context.Background()
mm := fourOnA(t)
_, said := saying(mm)
_ = mm.store.SaveLicence(ctx, sub("b", read(20, 95)))
_ = mm.store.SaveLicence(ctx, sub("c", read(40, 99)))
for range 2 {
_ = mm.m.SayConditions(ctx, false)
}
key := "licence.a.no-room"
if strings.Join(of(*said, key), ",") != "raised" {
t.Fatalf("said %+v", *said)
}
var c oc.Condition
for _, s := range *said {
if s.Key == key {
c = s
}
}
if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" ||
c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" ||
!strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") ||
!strings.Contains(c.Explanation, "b has 5 % room, its weekly window not below 90 %") ||
c.Resolved != "ace, g14, laptop, server have a licence with room again" {
t.Fatalf("no-room %+v", c)
}
_ = mm.store.SaveLicence(ctx, sub("b", read(10, 20)))
_ = mm.m.SayConditions(ctx, false)
if strings.Join(of(*said, key), ",") != "raised,cleared" {
t.Fatalf("said %v after a reading with room", of(*said, key))
}
}
// design 39 bus-and-switch §30/AC1, B39: a rising series of readings says near-limit once at the crossing and again only
// when its words change; with no other licence having room it needs the operator; a reading after the reset clears it.
func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
ctx := context.Background()
mm := fourOnA(t)
_, said := saying(mm)
key := "licence.a.near-limit"
var last oc.Condition
for _, w := range []float64{80, 89, 91, 91, 95} {
_ = mm.store.SaveLicence(ctx, sub("a", read(95, w))) // a session at 95 % takes no part (C4)
_ = mm.m.SayConditions(ctx, false)
for _, s := range *said {
if s.Key == key {
last = s
}
}
}
if strings.Join(of(*said, key), ",") != "raised,raised" || last.Headline != "a at 95% weekly" || last.Needs != "" ||
!strings.HasPrefix(last.Explanation, occ.NothingToDo+" ace, g14, laptop, server move to b at 99 %.") ||
!strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") {
t.Fatalf("said %v, last %+v", of(*said, key), last)
}
_ = mm.store.SaveLicence(ctx, sub("b", read(0, 95)))
_ = mm.store.SaveLicence(ctx, sub("c", read(0, 95)))
_ = mm.m.SayConditions(ctx, false)
if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" {
t.Fatalf("with no target said %+v", c)
}
_ = mm.store.SaveLicence(ctx, sub("a", read(3, 1)))
_ = mm.m.SayConditions(ctx, false)
if got := of(*said, key); got[len(got)-1] != "cleared" || len(got) != 4 {
t.Fatalf("after the reset said %v", got)
}
}
// design 39 bus-and-switch §31/AC1, B34, B38: three failed readings in a row, the latest success then older than
// usage_stale_minutes, say usage-unread naming the vendor's reason, needing the operator for the consumers bound to it;
// one success clears it. A subscription never read successfully is said once its adoption is that old.
func TestUsageUnreadIsSaidAndCleared(t *testing.T) {
ctx := context.Background()
mm := newMesh(t)
_, said := saying(mm)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
key := "licence." + KeyFor(licence1) + ".usage-unread"
mm.vendor.usageErr = errors.New("429 Too Many Requests: rate limited")
for i := 1; i <= 3; i++ {
mm.now = t0.Add(time.Duration(i) * 6 * time.Minute)
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
if got := len(of(*said, key)); (i < 3 && got != 0) || (i == 3 && got != 1) {
t.Fatalf("after %d failed readings said %v", i, of(*said, key))
}
}
c := (*said)[len(*said)-1]
if c.Severity != occ.SeverityWarning || c.Needs != "laptop are not moved off it while its usage is unknown" ||
!strings.Contains(c.Explanation, "the vendor answers: 429 Too Many Requests: rate limited") {
t.Fatalf("usage-unread %+v", c)
}
mm.vendor.usageErr = nil
_ = mm.m.ReadUsage(ctx)
if strings.Join(of(*said, key), ",") != "raised,cleared" {
t.Fatalf("after a success said %v", of(*said, key))
}
// B38: never read successfully since its adoption.
never := Licence{Name: "new@example.org", Kind: "subscription", AdoptedAt: mm.now.UnixMilli()}
_ = mm.store.SaveLicence(ctx, never)
_ = mm.m.SayConditions(ctx, false)
newKey := "licence." + KeyFor(never.Name) + ".usage-unread"
if len(of(*said, newKey)) != 0 {
t.Fatal("said at its adoption")
}
mm.now = mm.now.Add(16 * time.Minute)
_ = mm.m.SayConditions(ctx, false)
if strings.Join(of(*said, newKey), ",") != "raised" {
t.Fatalf("said %v of a licence never read", of(*said, newKey))
}
}
@@ -0,0 +1,68 @@
package main
// What every node holds, heard as events (design 39 bus-and-switch §23, §46, B12): the agent module says
// `holdings-changed` with its holdings report whole, at its start, on every change and when asked; the manager says
// `holdings.asked` at every start, after its conditions are judged, and on its resync, and takes each report it hears as that
// node's latest.
import (
"encoding/json"
"fmt"
"sort"
"sync"
"time"
)
// HoldingsChanged is the agent module's event saying what one node holds, which the manager consumes.
const HoldingsChanged = "claude-code.holdings-changed"
// HoldingsAsked is the manager's event asking every node to say what it holds again.
const HoldingsAsked = "holdings.asked"
// Heard is what every node last said it holds, by node.
type Heard struct {
mu sync.Mutex
reports map[string]Holdings
}
// Take keeps one `holdings-changed` as its node's latest report.
func (h *Heard) Take(body []byte) (Holdings, error) {
var r Holdings
if err := json.Unmarshal(body, &r); err != nil {
return r, fmt.Errorf("%s is not a holdings report: %w", HoldingsChanged, err)
}
if r.Node == "" {
return r, fmt.Errorf("%s names no node", HoldingsChanged)
}
h.mu.Lock()
defer h.mu.Unlock()
if h.reports == nil {
h.reports = map[string]Holdings{}
}
h.reports[r.Node] = r
return r, nil
}
// All is every node's latest report, by node's name.
func (h *Heard) All() []Holdings {
h.mu.Lock()
all := make([]Holdings, 0, len(h.reports))
for _, r := range h.reports {
all = append(all, r)
}
h.mu.Unlock()
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
return all
}
// Of is the generation node last reported applying.
func (h *Heard) Of(node string) int64 {
h.mu.Lock()
defer h.mu.Unlock()
return h.reports[node].Generation
}
// AskHoldings says `holdings.asked`, its time and who asks (§23).
func (m *Manager) AskHoldings(by string) error {
return m.Emit(HoldingsAsked, map[string]any{"at": m.Now().UTC().Format(time.RFC3339Nano), "by": by})
}
@@ -0,0 +1,24 @@
package main
// A licence's key taken by another (design 39 bus-and-switch §10, B6); the key form itself is the shared package's.
import (
"context"
. "claude-licence-manager/internal/licences"
)
// keyTaken names the licence other than name whose key is name's, or "" when none is.
func (m *Manager) keyTaken(ctx context.Context, name string) (string, error) {
all, err := m.Store.Licences(ctx)
if err != nil {
return "", err
}
key := KeyFor(name)
for _, l := range all {
if l.Name != name && KeyFor(l.Name) == key {
return l.Name, nil
}
}
return "", nil
}
@@ -0,0 +1,58 @@
package main
import (
"context"
"strings"
"testing"
"time"
. "claude-licence-manager/internal/licences"
)
// design 39 bus-and-switch §10: an adoption whose key another licence has is refused, in the audit and as an event.
func TestAnAdoptionWhoseKeyIsTakenIsRefused(t *testing.T) {
ctx := context.Background()
t.Run("an API key", func(t *testing.T) {
mm := newMesh(t)
_ = mm.store.SaveLicence(ctx, Licence{Name: "Ops-Key", Kind: "api-key"})
_, err := mm.m.adoptKey(ctx, "ops-key", "sk-ant-"+strings.Repeat("a", 40), "a test")
if err == nil || !strings.Contains(err.Error(), "Ops-Key") {
t.Fatalf("adopted a key whose key form is taken: %v", err)
}
if l, _ := mm.store.Licence(ctx, "ops-key"); l != nil {
t.Fatal("the clashing licence was kept")
}
assertRefused(t, mm)
})
t.Run("a login", func(t *testing.T) {
mm := newMesh(t)
_ = mm.store.SaveLicence(ctx, Licence{Name: "ACCT-1@example.org", Kind: "api-key"})
r := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute))
adopted, err := mm.m.Consider(ctx, []Holdings{r})
if err != nil || len(adopted) != 0 {
t.Fatalf("adopted %v (%v), want none", adopted, err)
}
if len(mm.vendor.exchanged) != 0 {
t.Fatalf("a refused login was exchanged: %v", mm.vendor.exchanged)
}
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-laptop")); o != Refused {
t.Fatalf("outcome %q, want refused", o)
}
assertRefused(t, mm)
})
}
func assertRefused(t *testing.T, mm *miniMesh) {
t.Helper()
said := false
for _, e := range mm.events {
said = said || strings.HasPrefix(e, "licence.refused ")
}
audited := false
for _, a := range mm.audit.under("") {
audited = audited || a["what"] == "refused"
}
if !said || !audited {
t.Fatalf("refusal said %v, audited %v: %v", said, audited, mm.events)
}
}
@@ -1,10 +1,10 @@
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control
// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
//
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): the licence manager's manager bundle (design 39
// parts §2), loaded by the module's runtime of its own under its own account (`runs-as claude-licence-manager`, design
// 39 bus-and-switch C1), never by the machine's tool runner, and speaking MCP to it over stdio through the Go SDK (ADR
// 0193, ADR 0198). It runs long (the conditions judged at its start, the usage timer, the switch rule): it watches what
// every node reports holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads
// usage. It answers the seat's ten verbs and the module's three acting tools, and is the only part that writes the
// module's kept data and logs; the reading tools are the tools bundle's (cmd/tools, parts §3, §4).
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
@@ -16,12 +16,13 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
. "claude-licence-manager/internal/licences"
)
// Seat is the role this module holds.
@@ -32,14 +33,6 @@ func say(format string, args ...any) {
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "prepare" {
if err := prepare(); err != nil {
say("preparing the store failed: %v", err)
os.Exit(1)
}
say("the store's schema is what this version needs")
return
}
go daemon()
if err := stdio.Serve("", tools()); err != nil {
say("%v", err)
@@ -47,17 +40,6 @@ func main() {
}
}
func prepare() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
store, err := PgStoreFromEnv(ctx)
if err != nil {
return err
}
defer store.Close()
return store.Migrate(ctx)
}
// ---- what the binary is handed -----------------------------------------------------------------------
var (
@@ -66,26 +48,18 @@ var (
)
// manager builds the rules' dependencies once, from the module's words (ADR 0192): file paths, never
// values. A failure is said and tried again on the next call, so a database that arrives late is not fatal.
// values. A failure is said and tried again on the next call, so a key pair that cannot be made yet is not fatal.
func manager() (*Manager, error) {
buildMu.Lock()
defer buildMu.Unlock()
if built != nil {
return built, nil
}
dir, keyFile := os.Getenv("MESH_LICENCE_STATE"), os.Getenv("MESH_LICENCE_KEY_FILE")
if dir == "" || keyFile == "" {
return nil, errors.New("MESH_LICENCE_STATE and MESH_LICENCE_KEY_FILE are not set: the mesh renders them for this module")
dir := os.Getenv("MESH_LICENCE_STATE")
if dir == "" {
return nil, errors.New("MESH_LICENCE_STATE is not set: the mesh renders it for this module")
}
crypt, err := CryptFromFile(keyFile)
if err != nil {
return nil, err
}
keys, err := keypair(dir)
if err != nil {
return nil, err
}
store, err := PgStoreFromEnv(context.Background())
keys, err := keypair(dir, say)
if err != nil {
return nil, err
}
@@ -93,11 +67,12 @@ func manager() (*Manager, error) {
if n := os.Getenv("MESH_NODE"); n != "" {
node = n
}
bindings := stdio.State("bindings")
built = &Manager{
Store: store,
Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"),
LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"),
AuditLog: stdio.Log("audit"), Now: time.Now},
Vendor: LiveVendor(),
Crypt: crypt,
Grants: VaultGrants{Keys: keys},
Keys: keys,
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
var a GrantAnswer
@@ -107,50 +82,103 @@ func manager() (*Manager, error) {
}
return a, json.Unmarshal(raw, &a)
},
PutBinding: func(_ context.Context, consumer string, b BindingState) error {
_, err := bindings.Put(consumer, b)
Reported: heard.Of,
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
Tell: func(_ context.Context, headline string, lines []string) error {
_, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"subject": Seat, "headline": headline,
"lines": lines})
return err
},
DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) },
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
Now: time.Now,
Log: say,
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
Now: time.Now,
Log: say,
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"),
Conditions: stdio.State("conditions"),
Say: SayCondition,
}
return built, nil
}
// keypair is this module's own, made once in its state directory; the private half never leaves it.
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
func keypair(dir string) (KeyPair, error) {
// Written whole, then linked into place, so a second process reads the first's key and never half of it. A pair it
// finds and cannot read (another account's, left before the module ran as its own) is made anew in its place and said
// in its journal (design 39 bus-and-switch C2): every use passes the public key per ask, so nothing depends on the old.
func keypair(dir string, log func(string, ...any)) (KeyPair, error) {
file := filepath.Join(dir, "manager-key.json")
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
k, err := GenerateKeyPair()
if err != nil {
if err := writeKeyPair(dir, file, false); err != nil {
return KeyPair{}, err
}
raw, _ := json.Marshal(k)
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return KeyPair{}, err
}
_ = os.Link(tmp, file) // fails when another made it first, which is right
_ = os.Remove(tmp)
}
k, err := readKeyPair(file)
if err == nil {
return k, nil
}
log("the key pair %s cannot be read (%v); a new one is made in its place", file, err)
if err := writeKeyPair(dir, file, true); err != nil {
return KeyPair{}, err
}
return readKeyPair(file)
}
func readKeyPair(file string) (KeyPair, error) {
raw, err := os.ReadFile(file)
if err != nil {
return KeyPair{}, err
}
var k KeyPair
return k, json.Unmarshal(raw, &k)
if err := json.Unmarshal(raw, &k); err != nil {
return KeyPair{}, err
}
if k.PublicKey == "" || k.PrivateKey == "" {
return KeyPair{}, errors.New("it holds no whole key pair")
}
return k, nil
}
// writeKeyPair writes a new pair whole beside file, then links it into place, or, replacing, renames it over the one
// that cannot be read.
func writeKeyPair(dir, file string, replace bool) error {
k, err := GenerateKeyPair()
if err != nil {
return err
}
raw, _ := json.Marshal(k)
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
if replace {
return os.Rename(tmp, file)
}
_ = os.Link(tmp, file) // fails when another made it first, which is right
_ = os.Remove(tmp)
return nil
}
// ---- the long-running half ---------------------------------------------------------------------------
// heard is what every node last said it holds (ADR 0206, design 39 bus-and-switch §23), by node.
var heard = &Heard{}
// judgedAtStart judges every condition anew at the start, before the manager asks what the nodes hold and starts its
// timers: raised again each it holds, cleared each key of `conditions` it no longer holds, carry-over-failed among
// them when present (design 39 parts §11, bus-and-switch §9). It waits for nothing to be carried over.
func judgedAtStart() {
<-stdio.Served()
m, err := manager()
if err != nil {
say("the conditions cannot be judged at the start: %v", err)
return
}
if err := m.SayConditions(context.Background(), true); err != nil {
say("saying the conditions at the start: %v", err)
}
}
func daemon() {
var mu sync.Mutex
reports := map[string]Holdings{}
judgedAtStart()
var passing sync.Mutex
pass := func() {
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
@@ -160,14 +188,7 @@ func daemon() {
say("not ready: %v", err)
return
}
mu.Lock()
all := make([]Holdings, 0, len(reports))
for _, r := range reports {
all = append(all, r)
}
mu.Unlock()
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
adopted, err := m.Consider(context.Background(), all)
adopted, err := m.Consider(context.Background(), heard.All())
if err != nil {
say("considering the reports failed: %v", err)
}
@@ -176,48 +197,45 @@ func daemon() {
}
}
// What every node holds (ADR 0206): the whole current set, then each change. Asked again until it
// answers — the channel to the runtime opens as the bundle starts, and the agent module's state may
// arrive after this one.
// What every node holds (ADR 0206, design 39 bus-and-switch §23): each `holdings-changed` heard is that node's
// latest report and starts a pass; once heard, every node is asked to say it again (B12: at every start, after the
// conditions are judged). Asked again until it answers — the channel to the runtime opens as the bundle starts.
go func() {
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
for attempt := 0; ; attempt++ {
err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error {
mu.Lock()
if c.Op == "put" {
var h Holdings
if json.Unmarshal(c.Value, &h) == nil {
reports[c.Key] = h
}
} else {
delete(reports, c.Key)
}
mu.Unlock()
// During the current values the pass waits for the whole set: newest login first needs all.
if !c.Current {
go pass()
err := stdio.Subscribe(HoldingsChanged, func(e stdio.Envelope) error {
if _, err := heard.Take(e.Body); err != nil {
say("%v", err)
return nil
}
go pass()
return nil
})
if err == nil {
mu.Lock()
n := len(reports)
mu.Unlock()
say("watching what %d node(s) hold", n)
pass()
say("hearing what the nodes hold")
if m, err := manager(); err != nil {
say("not ready to ask what the nodes hold: %v", err)
} else if err := m.AskHoldings(Itself); err != nil {
say("asking what the nodes hold could not be said: %v", err)
}
return
}
pause := time.Minute
if attempt < len(waits) {
pause = waits[attempt]
}
say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause)
say("what the nodes hold cannot be heard yet (%v); asking again in %s", err, pause)
time.Sleep(pause)
}
}()
refresh := time.NewTicker(time.Minute)
usage := time.NewTicker(5 * time.Minute)
// Every usage_minutes, read again at every reading (§24, §33): the manager's one timer of its own readings.
every := Defaults.UsageEvery
if m, err := manager(); err == nil {
every = m.settings().UsageEvery
}
usage := time.NewTimer(every)
for {
select {
case <-refresh.C:
@@ -237,7 +255,9 @@ func daemon() {
if err := m.ReadUsage(context.Background()); err != nil {
say("reading usage failed: %v", err)
}
every = m.settings().UsageEvery
}
usage.Reset(every)
}
}
}
@@ -266,6 +286,28 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
}}
}
// sinceOf is the argument since, RFC 3339; absent is the zero time.
func sinceOf(a map[string]any) (time.Time, error) {
v, _ := a["since"].(string)
if strings.TrimSpace(v) == "" {
return time.Time{}, nil
}
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(v))
if err != nil {
return time.Time{}, fmt.Errorf("since is not a time in RFC 3339: %w", err)
}
return t, nil
}
// limitOf is the argument limit: def when absent or not positive, at most most.
func limitOf(a map[string]any, def, most int) int {
limit := def
if v, ok := a["limit"].(float64); ok && v > 0 {
limit = int(v)
}
return min(limit, most)
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
@@ -278,11 +320,15 @@ func two(a map[string]any, k1, k2 string) (string, string, error) {
func tools() []stdio.Tool {
consumer := str("the node's name")
return []stdio.Tool{
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.",
return append([]stdio.Tool{
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it, its latest usage with its reset times, its room or why it is not known, whether it is where a full licence's consumers move next, when and from where it was adopted, and its open conditions. Never a token.",
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.",
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }),
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given; with consumer, that consumer's alone.",
map[string]any{"consumer": str("a consumer; absent for every one")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, _ := a["consumer"].(string)
return m.BindingsOf(ctx, strings.TrimSpace(c))
}),
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
@@ -318,15 +364,18 @@ func tools() []stdio.Tool {
}
return m.RotateDue(ctx)
}),
verb("usage", "Usage readings, the latest first, for every licence or one.",
map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}},
verb("usage", "Usage readings, the latest first, for every licence or one: the flattened reading with its three reset times, none before since, at most limit (default 20, at most 2000), and with raw the vendor's answer whole.",
map[string]any{"licence": str("a licence; absent for all"), "since": str("a time, RFC 3339: no reading before it"),
"limit": map[string]any{"type": "number", "description": "how many readings (default 20, at most 2000)"},
"raw": map[string]any{"type": "boolean", "description": "also the vendor's answer whole"}},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
l, _ := a["licence"].(string)
limit := 20
if v, ok := a["limit"].(float64); ok && v > 0 {
limit = int(v)
since, err := sinceOf(a)
if err != nil {
return nil, err
}
return m.Store.Usage(ctx, l, limit)
raw, _ := a["raw"].(bool)
return m.Store.Usage(ctx, l, since, limitOf(a, 20, 2000), raw)
}),
verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.",
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"),
@@ -356,14 +405,14 @@ func tools() []stdio.Tool {
nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
return map[string]any{"public_key": m.Keys.PublicKey}, nil
}),
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
verb("current", "For a consumer's agent module: which licence it is bound to, the licence's key, kind and generation, its health (ok, failing or expiring) and when its access token expires; never the token, which the vault hands. Null when it is bound to nothing.",
map[string]any{"consumer": consumer},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, k, err := two(a, "consumer", "public_key")
c, err := text(a, "consumer")
if err != nil {
return nil, err
}
return m.Current(ctx, c, k)
return m.Current(ctx, c)
}),
}
}, moduleTools()...)
}
@@ -23,7 +23,12 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
. "claude-licence-manager/internal/licences"
)
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
@@ -53,17 +58,6 @@ type Holdings struct {
Generation int64 `json:"generation"`
}
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
// generation.
type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
type Cause struct {
@@ -90,63 +84,52 @@ func CauseOf(ctx context.Context) Cause {
return Cause{Why: "a rule of this module's own", By: Itself}
}
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
_ = m.Store.Audit(ctx, what, detail)
// record says one change of what a consumer holds, the same fields in two places (novox/hq issue 625): a line in
// the journal and an entry of the audit. The bus hears it as binding.changed (§21); `licence.bound`, `released` and
// `rotated` are said no more (B16). Never a token.
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error {
if err := m.Store.Audit(ctx, what, detail); err != nil {
m.Log("%s, but %v", line, err)
return err
}
m.Log("%s", line)
_ = m.Emit("licence."+what, detail)
return nil
}
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
if from != "" {
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
// BindingChanged is the event of a consumer's binding written or deleted, said as `binding.changed.<consumer>`: the consumer is
// its further part, the one form of G's seat events (novox/hq design 47 G A-134), so a reader adopts it.
const BindingChanged = "binding.changed"
// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a
// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token.
func (m *Manager) changed(b Binding, from string) {
key := ""
if b.Licence != "" {
key = KeyFor(b.Licence)
}
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
"why": c.Why, "by": c.By})
_ = m.Emit(BindingChanged+"."+b.Consumer, map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key,
"kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By,
"at": m.Now().UTC().Format(time.RFC3339Nano)})
}
// Settings are the manager's own, declared with defaults (design 39 §8).
type Settings struct {
Cadence time.Duration // rotate a grant once older than this
Floor time.Duration // refresh in any case with less than this left
FailuresToNotify int
Cooldown time.Duration // at most one notification per licence in this window
RefreshWarn time.Duration // warn this long before a refresh token itself expires
// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27).
func switchedFields(from, to string, consumers []string, c Cause) map[string]any {
return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why}
}
// Defaults are the settings a fresh mesh runs with.
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour}
// SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
func SettingsFrom(path string) Settings {
s := Defaults
raw, err := os.ReadFile(path)
if err != nil {
return s
// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with
// §27's fields), and as binding.changed.
func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error {
what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence)
detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation,
"why": c.Why, "by": c.By}
if from != "" && from != b.Licence {
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence)
detail = switchedFields(from, b.Licence, []string{b.Consumer}, c)
}
var f map[string]float64
if json.Unmarshal(raw, &f) != nil {
return s
}
set := func(k string, unit time.Duration, into *time.Duration) {
if v := f[k]; v > 0 {
*into = time.Duration(v * float64(unit))
}
}
set("cadence_minutes", time.Minute, &s.Cadence)
set("floor_minutes", time.Minute, &s.Floor)
set("cooldown_hours", time.Hour, &s.Cooldown)
if v := f["refresh_warn_days"]; v > 0 {
s.RefreshWarn = time.Duration(v * float64(24*time.Hour))
}
if v := f["failures_to_notify"]; v > 0 {
s.FailuresToNotify = int(v)
}
return s
err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail)
m.changed(b, from)
return err
}
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
@@ -160,20 +143,31 @@ type GrantAnswer struct {
type Manager struct {
Store Store
Vendor Vendor
Crypt *Crypt
// Grants is the vault, which holds every licence's grant and access token (§15, §16).
Grants Grants
Keys KeyPair
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
// is the node not answering; a nil Sealed is no login waiting.
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
// PutBinding and DeleteBinding are this module's `bindings` state.
PutBinding func(ctx context.Context, consumer string, b BindingState) error
DeleteBinding func(ctx context.Context, consumer string) error
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
// Reported is the binding generation a node last reported applying (B11), 0 when none is known.
Reported func(node string) int64
Emit func(event string, body map[string]any) error
// Tell tells the operator one notice that needs no answer, through the seat operator-channel's tell (§28).
Tell func(ctx context.Context, headline string, lines []string) error
Now func() time.Time
Log func(format string, args ...any)
// Holder names this process in a lease, so a second run is told apart.
Holder string
Settings Settings
Holder string
// Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile
// keeps them.
Settings Settings
SettingsFile string
settingsMu sync.RWMutex
// Conditions is the state `conditions` (design 39 bus-and-switch §9), and Say says one condition on the operator
// channel; either nil says none.
Conditions State
Say func(c oc.Condition) error
conditionsMu sync.Mutex
}
const leaseFor = 2 * time.Minute
@@ -239,17 +233,9 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
// exchanged. Answers what was adopted.
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
// **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss
// counts from one again, while every node still holds the number it last applied. The nodes no longer
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
// skipped. So the count is moved past every number reported, before anything here binds.
var highest int64
for _, r := range reports {
highest = max(highest, r.Generation)
}
if err := m.Store.GenerationsAbove(ctx, highest); err != nil {
return nil, err
}
// **Never a generation a node has already passed** (novox/hq issue 243, B11): each consumer's count is its own,
// and moves past the number its node reported applying.
floor := m.floorIn(reports)
byAccount, err := m.CandidatesIn(ctx, reports)
if err != nil {
return nil, err
@@ -262,19 +248,19 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
if err := m.bindReporters(ctx, reports); err != nil {
if err := m.bindReporters(ctx, reports, floor); err != nil {
return nil, err
}
var adopted []string
for _, account := range accounts {
list := byAccount[account]
key := "account:" + account
key := "account." + account
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
if err != nil || !ok {
continue
}
for i, c := range list {
name, err := m.adoptOne(ctx, c, reports)
name, err := m.adoptOne(ctx, c, reports, floor)
if err != nil {
m.Log("adopting %s's login failed: %v", c.Node, err)
continue
@@ -293,7 +279,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
}
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor func(string) int64) error {
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
continue
@@ -311,21 +297,19 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
if l == nil {
continue
}
b, err := m.Store.Bind(ctx, rep.Node, l.Name)
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
if err != nil {
return err
}
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
if err := m.moved(ctx, b, "", c); err != nil {
return err
}
}
return nil
}
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) {
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, floor func(string) int64) (string, error) {
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
if err != nil {
// Not answering is not an answer: asked again on the next pass.
@@ -348,12 +332,34 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
return "", nil
}
// A new licence whose key another licence already has is refused before its grant is exchanged
// (design 39 bus-and-switch §10).
if held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID); err != nil {
return "", err
} else if held == nil {
taken, err := m.keyTaken(ctx, NameFor(c.Identity))
if err != nil {
return "", err
}
if taken != "" {
why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why)
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}); err != nil {
return "", err
}
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why})
return "", nil
}
}
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
// only holder of a live refresh token for the account.
r := m.Vendor.Refresh(ctx, offered)
if !r.OK {
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason})
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}); err != nil {
return "", err
}
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
@@ -363,7 +369,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
if r.Account != "" && r.Account != c.Identity.AccountUUID {
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account})
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}); err != nil {
return "", err
}
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
"reason": "the account the node reported is not the one the vendor answered for"})
return "", nil
@@ -375,9 +383,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
}
now := m.Now().UnixMilli()
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID, AdoptedFrom: c.Node}
if held != nil {
l.Name, l.NotifiedAt = held.Name, held.NotifiedAt
// A newer login keeps what the key holds beside its grant: its latest reading (§2, §24).
l.Name, l.NotifiedAt, l.Generation, l.Usage = held.Name, held.NotifiedAt, held.Generation, held.Usage
if l.Email == "" {
l.Email = held.Email
}
@@ -385,7 +394,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
l.OrganizationUUID = held.OrganizationUUID
}
}
m.keep(&l, r.Grant)
if err := m.keep(ctx, &l, r.Grant); err != nil {
return "", err
}
l.AdoptedAt = max(now, c.ChangedAt)
if err := m.Store.SaveLicence(ctx, l); err != nil {
return "", err
@@ -395,18 +406,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
why = "a newer login"
}
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
"vendorNamedAccount": r.Account != ""})
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
"vendorNamedAccount": r.Account != ""}); err != nil {
return "", err
}
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
ctx = WithCause(ctx, cause)
// The consumers already bound to it take the new grant first, so each consumer this adoption moves is said once
// (§21).
if err := m.publishAdvance(ctx, l, floor); err != nil {
return "", err
}
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
return "", err
} else if b == nil || b.Licence != l.Name {
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
moved, err := m.Store.Bind(ctx, c.Node, l.Name, Move{Kind: l.Kind, Why: cause.Why, By: cause.By, Floor: floor})
if err != nil {
return "", err
}
@@ -414,7 +432,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
if b != nil {
from = b.Licence
}
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
if err := m.moved(ctx, moved, from, cause); err != nil {
return "", err
}
}
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
@@ -423,23 +443,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
continue
}
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil {
if err := m.moved(ctx, b, "", bc); err != nil {
return "", err
}
}
}
if err := m.publishAdvance(ctx, l); err != nil {
return "", err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
return l.Name, nil
}
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh.
func (m *Manager) keep(l *Licence, g FullGrant) {
raw, _ := json.Marshal(g)
l.Sealed = m.Crypt.Seal(string(raw))
// keep hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39
// bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns.
func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error {
grant, _ := json.Marshal(g)
if err := m.handOver(ctx, l, string(grant), accessOf(g)); err != nil {
return err
}
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
l.AccessExpiresAt = g.ExpiresAt
if g.RefreshTokenExpiresAt != nil {
@@ -447,30 +469,73 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
}
l.Failures = 0
l.RotatedAt = m.Now().UnixMilli()
return nil
}
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
bindings, err := m.Store.Advance(ctx, l.Name)
if err != nil {
return err
}
c := CauseOf(ctx)
for _, b := range bindings {
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return err
// accessOf is a subscription's `access` secret: its access token and expiry, never its refresh token (§15).
func accessOf(g FullGrant) string {
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
return string(access)
}
// handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15).
func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error {
generation := l.Generation + 1
for _, part := range []struct{ name, value string }{{PartGrant, grant}, {PartAccess, access}} {
if err := m.Grants.Put(ctx, KeyFor(l.Name), part.name, generation, part.value); err != nil {
return fmt.Errorf("the vault did not take the %s of %s: %w", part.name, l.Name, err)
}
}
l.Generation = generation
return nil
}
// grantOf reads a subscription's grant from the vault for one act (§16); the caller drops it when the act ends.
func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
var g FullGrant
plain, err := m.Grants.Get(ctx, KeyFor(l.Name), PartGrant)
if err != nil {
return g, fmt.Errorf("the grant of %s cannot be read from the vault: %w", l.Name, err)
}
return g, json.Unmarshal([]byte(plain), &g)
}
// publishAdvance gives every consumer of a licence a new generation, with the cause ctx carries, so the node says why
// it fetched again (novox/hq issue 625).
func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error {
c := CauseOf(ctx)
advanced, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
for _, b := range advanced {
m.changed(b, b.Licence)
}
return err
}
// floorIn is the floor of each consumer's generation (B11): the higher of what its node reported in reports and what
// Reported says it last applied. A worker's node is the part of its name before the first dot (ADR 0183 §19).
func (m *Manager) floorIn(reports []Holdings) func(string) int64 {
return func(consumer string) int64 {
node, _, _ := strings.Cut(consumer, ".")
var f int64
for _, r := range reports {
if r.Node == node {
f = max(f, r.Generation)
}
}
if m.Reported != nil {
f = max(f, m.Reported(node))
}
return f
}
}
// ---- keeping grants alive ----------------------------------------------------------------------------
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
func Due(l Licence, now time.Time, s Settings) bool {
if l.Kind != "subscription" || l.Sealed == "" {
if l.Kind != "subscription" || l.Generation == 0 {
return false
}
ms := now.UnixMilli()
@@ -484,7 +549,7 @@ func Due(l Licence, now time.Time, s Settings) bool {
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
// whatever the age — the seat's `refresh` verb.
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
key := "licence:" + name
key := "licence." + KeyFor(name)
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
if err != nil {
return nil, err
@@ -500,37 +565,38 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
if l == nil {
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
}
if l.Kind != "subscription" || l.Sealed == "" {
if l.Kind != "subscription" || l.Generation == 0 {
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
}
now := m.Now()
if !force && !Due(*l, now, m.Settings) {
if !force && !Due(*l, now, m.settings()) {
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
}
plain, err := m.Crypt.Open(l.Sealed)
g, err := m.grantOf(ctx, *l)
if err != nil {
return nil, err
}
var g FullGrant
if err := json.Unmarshal([]byte(plain), &g); err != nil {
return nil, err
}
r := m.Vendor.Refresh(ctx, g)
g = FullGrant{}
if !r.OK {
l.Failures++
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
_ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures})
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil {
return nil, err
}
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.settings().FailuresToNotify)
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
}
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
}
m.keep(l, r.Grant)
if err := m.keep(ctx, l, r.Grant); err != nil {
return nil, err
}
if l.RefreshExpiresAt != 0 {
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
left < m.Settings.RefreshWarn)
left < m.settings().RefreshWarn)
}
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
@@ -545,10 +611,12 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
}
}
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
if err := m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
if err := m.publishAdvance(ctx, *l); err != nil {
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil {
return nil, err
}
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
@@ -560,7 +628,7 @@ func (m *Manager) notify(l *Licence, why string, when bool) {
return
}
now := m.Now().UnixMilli()
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() {
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.settings().Cooldown.Milliseconds() {
return
}
l.NotifiedAt = now
@@ -575,7 +643,7 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
}
var out []map[string]any
for _, l := range all {
if Due(l, m.Now(), m.Settings) {
if Due(l, m.Now(), m.settings()) {
r, err := m.Rotate(ctx, l.Name, false)
if err != nil {
return out, err
@@ -586,79 +654,165 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
return out, nil
}
// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers.
// ReadUsage reads every subscription's usage, on the manager's cadence (§24): each reading written, the rule applied
// after it (§26), and the conditions said (§9).
func (m *Manager) ReadUsage(ctx context.Context) error {
_, err := m.ReadUsageOf(ctx, "", false)
return err
}
// Read is one reading as licences_read_usage answers it (§38): the licence, the reading or why it failed, and what §26
// did or, with dry, would do.
type Read struct {
Licence string `json:"licence"`
Reading *UsageReading `json:"reading,omitempty"`
Error string `json:"readError,omitempty"`
Switches []Switch `json:"switches"`
}
// ReadUsageOf reads the usage of one subscription, or every one when licence is empty, as §24 does, and applies §26
// after each reading unless dry; dry answers what §26 would do (§32, §38).
func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([]Read, error) {
if err := m.ReadSettings(ctx); err != nil {
return nil, err
}
all, err := m.Store.Licences(ctx)
if err != nil {
return err
return nil, err
}
out := []Read{}
for _, l := range all {
if l.Kind != "subscription" || l.Sealed == "" {
if l.Kind != "subscription" || l.Generation == 0 || (licence != "" && l.Name != licence) {
continue
}
plain, err := m.Crypt.Open(l.Sealed)
// The reading takes the licence's grant under its lease, so it never reads a grant a refresh is replacing (§4,
// §16); a grant the vault does not answer is a failed reading like a vendor's refusal (§31).
key := "licence." + KeyFor(l.Name)
leased, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
if err != nil {
return err
return out, err
}
var g FullGrant
_ = json.Unmarshal([]byte(plain), &g)
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
if err != nil || raw == nil {
if !leased {
out = append(out, Read{Licence: l.Name, Error: "another run holds its lease", Switches: []Switch{}})
continue
}
unlease := func() { _ = m.Store.Unlease(ctx, key, m.Holder) }
if fresh, err := m.Store.Licence(ctx, l.Name); err == nil && fresh != nil {
l = *fresh
}
var raw map[string]any
g, err := m.grantOf(ctx, l)
if err == nil {
raw, err = m.Vendor.Usage(ctx, g.AccessToken)
}
g = FullGrant{}
if err == nil && raw == nil {
err = errors.New("the usage endpoint answered nothing")
}
if err != nil {
// A failed reading keeps the last percentages and writes the vendor's status and reason (§24).
kept := UsageReading{}
if l.Usage != nil {
kept = *l.Usage
}
kept.ReadError = err.Error()
l.Usage = &kept
err := m.Store.SaveLicence(ctx, l)
unlease()
if err != nil {
return out, err
}
m.Log("reading the usage of %s failed: %v", l.Name, err)
out = append(out, Read{Licence: l.Name, Error: kept.ReadError, Switches: []Switch{}})
continue
}
reading := FlattenUsage(raw)
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
return err
unlease()
return out, err
}
// The latest reading beside its licence, so licences answers from one key (§2).
latest := reading
latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano)
l.Usage = &latest
err = m.Store.SaveLicence(ctx, l)
unlease()
if err != nil {
return out, err
}
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
"weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct})
"sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct,
"weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct,
"sonnetResetsAt": reading.SonnetResetsAt})
// On each reading, a licence at its threshold moves every consumer bound to it (§26).
acts, err := m.ApplyRule(ctx, l.Name, false, dry)
if acts == nil {
acts = []Switch{}
}
out = append(out, Read{Licence: l.Name, Reading: &latest, Switches: acts})
if err != nil {
return out, err
}
}
return nil
// What the readings and the settings now hold is said, each condition only when what it says changed (§9).
return out, m.SayConditions(ctx, false)
}
// ---- the seat's verbs --------------------------------------------------------------------------------
// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access
// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to
// Current is which licence a consumer is bound to and its health, never the token (design 39 bus-and-switch §18, B7,
// B19): the licence, its key (the name of its secrets in the vault), kind, generation, health and accessExpiresAt, and
// for a subscription the account's identity the agent module points its account file at. Nil when it is bound to
// nothing.
func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) {
if !strings.Contains(publicKey, "PUBLIC KEY") {
return nil, errors.New("current seals to the consumer's public key, and none was given")
}
func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, error) {
b, err := m.Store.Binding(ctx, consumer)
if err != nil || b == nil {
return nil, err
}
l, err := m.Store.Licence(ctx, b.Licence)
if err != nil || l == nil || l.Sealed == "" {
if err != nil || l == nil {
return nil, err
}
plain, err := m.Crypt.Open(l.Sealed)
if err != nil {
return nil, err
out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation,
"health": Health(*l, m.Now(), m.settings())}
if l.AccessExpiresAt != 0 {
out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)
}
handed := plain
if l.Kind == "subscription" {
var g FullGrant
if err := json.Unmarshal([]byte(plain), &g); err != nil {
return nil, err
}
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
handed = string(access)
}
box, err := Seal(handed, publicKey)
if err != nil {
return nil, err
}
out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box}
if l.AccountUUID != "" {
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
}
return out, nil
}
// Health is a licence's health (B19): `failing` once its refresh failed failures_to_notify times in a row, `expiring`
// when its refresh token expires within refresh_warn_days, else `ok`.
func Health(l Licence, now time.Time, s Settings) string {
switch {
case l.Failures >= s.FailuresToNotify:
return "failing"
case l.RefreshExpiresAt != 0 && l.RefreshExpiresAt-now.UnixMilli() < s.RefreshWarn.Milliseconds():
return "expiring"
}
return "ok"
}
// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged, each with
// its licence's key beside the §3 fields (B25).
func (m *Manager) BindingsOf(ctx context.Context, consumer string) ([]Binding, error) {
all, err := m.Store.Bindings(ctx)
if err != nil {
return nil, err
}
out := []Binding{}
for _, b := range all {
if consumer == "" || b.Consumer == consumer {
b.Key = KeyFor(b.Licence)
out = append(out, b)
}
}
return out, nil
}
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
@@ -673,7 +827,8 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
if err != nil {
return nil, err
}
b, err := m.Store.Bind(ctx, consumer, licence)
c := CauseOf(ctx)
b, err := m.Store.Bind(ctx, consumer, licence, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: m.floorIn(nil)})
if err != nil {
return nil, err
}
@@ -681,12 +836,12 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
if before != nil {
from = before.Licence
}
c := CauseOf(ctx)
m.moved(ctx, consumer, from, licence, b.Generation, c)
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
if err := m.moved(ctx, b, from, c); err != nil {
return nil, err
}
if from != "" && from != licence {
_ = m.Emit("licence.switched", switchedFields(from, licence, []string{consumer}, c)) // one per act (§27, §36)
}
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
}
@@ -699,12 +854,12 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
}
if err := m.DeleteBinding(ctx, consumer); err != nil {
c := CauseOf(ctx)
if err := m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
c := CauseOf(ctx)
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence)
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
}
@@ -751,22 +906,44 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
if held != nil && held.Kind != "api-key" {
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
}
l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()}
taken, err := m.keyTaken(ctx, name)
if err != nil {
return nil, err
}
if taken != "" {
why := "its key " + KeyFor(name) + " is the key of the licence " + taken
if err := m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}); err != nil {
return nil, err
}
_ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
return nil, fmt.Errorf("%s is refused: %s", name, why)
}
l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli(), AdoptedFrom: from}
if held != nil {
l.Generation, l.Usage = held.Generation, held.Usage
}
if err := m.handOver(ctx, &l, key, key); err != nil {
return nil, err
}
if err := m.Store.SaveLicence(ctx, l); err != nil {
return nil, err
}
c := CauseOf(ctx)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
if err := m.publishAdvance(ctx, l); err != nil {
if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil {
return nil, err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
}
// Licences is each licence as a person reads it: health and who is bound, never a token.
// Licences is each licence as a person reads it (design 39 bus-and-switch §34, §47, B19, B40): today's fields, its
// latest usage with its reset times, its room or why it is not known, whether it is the target a full licence's
// consumers move to next, when and from where it was adopted, and the conditions open about it. Never a token.
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
all, err := m.Store.Licences(ctx)
if err != nil {
@@ -776,27 +953,52 @@ func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
if err != nil {
return nil, err
}
open, err := KeptConditions(m.Conditions)
if err != nil {
return nil, err
}
s := m.settings()
stamp := func(ms int64) any {
if ms == 0 {
return nil
}
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}
targets := map[string]bool{}
for _, l := range all {
if len(consumersOf(l.Name, bindings)) > 0 {
if to, _ := m.targetFor(l, all, s); to != "" {
targets[to] = true
}
}
}
out := []map[string]any{}
for _, l := range all {
bound := []string{}
for _, b := range bindings {
if b.Licence == l.Name {
bound = append(bound, b.Consumer)
}
bound := consumersOf(l.Name, bindings)
if bound == nil {
bound = []string{}
}
account := l.Email
if account == "" {
account = l.AccountUUID
}
var room any = m.roomWords(l, s)
if r, known := Room(l, m.Now(), s); known {
room = r
}
conditions := []map[string]any{}
prefix := "licence." + KeyFor(l.Name) + "."
for _, c := range open {
if strings.HasPrefix(c.Body.Key, prefix) {
conditions = append(conditions, map[string]any{"key": c.Body.Key, "severity": c.Body.Severity,
"headline": c.Body.Headline, "since": c.Since})
}
}
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
"accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt),
"rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound})
"organisation": l.OrganizationUUID, "accessExpiresAt": stamp(l.AccessExpiresAt),
"refreshExpiresAt": stamp(l.RefreshExpiresAt), "rotatedAt": stamp(l.RotatedAt), "failures": l.Failures,
"bound": bound, "usage": l.Usage, "room": room, "target": targets[l.Name], "adoptedAt": stamp(l.AdoptedAt),
"adoptedFrom": l.AdoptedFrom, "conditions": conditions})
}
return out, nil
}
@@ -10,6 +10,8 @@ import (
"sync"
"testing"
"time"
. "claude-licence-manager/internal/licences"
)
var t0 = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
@@ -22,6 +24,9 @@ type stubVendor struct {
issued int
account string
now func() time.Time
// usage is what the usage endpoint answers, and usageErr its failure; nil answers a session window alone.
usage map[string]any
usageErr error
}
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
@@ -44,31 +49,89 @@ func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
}
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
if v.usageErr != nil {
return nil, v.usageErr
}
if v.usage != nil {
return v.usage, nil
}
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
}
// memGrants is the vault as the manager sees it: every licence's two secrets by name, the newest generation kept.
type memGrants struct {
mu sync.Mutex
held map[string]heldGrant
puts []string
// refuseAccess is the vault refusing a licence's access token to the manager's machine, as it does for a licence no
// binding there names (§17).
refuseAccess bool
}
type heldGrant struct {
generation int64
value string
}
func (g *memGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
g.mu.Lock()
defer g.mu.Unlock()
name := SecretName(key, part)
if h, ok := g.held[name]; ok && h.generation == generation && h.value == value {
return nil // the vault keeps what it holds, unchanged
} else if ok && h.generation >= generation {
return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation)
}
g.held[name] = heldGrant{generation, value}
g.puts = append(g.puts, fmt.Sprintf("%s@%d", name, generation))
return nil
}
func (g *memGrants) Get(_ context.Context, key, part string) (string, error) {
g.mu.Lock()
defer g.mu.Unlock()
h, ok := g.held[SecretName(key, part)]
if g.refuseAccess && part == PartAccess {
return "", fmt.Errorf("no consumer on this machine is bound to the licence %s", key)
}
if !ok {
return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part))
}
return h.value, nil
}
type miniMesh struct {
m *Manager
store *MemoryStore
vendor *stubVendor
logins map[string]FullGrant // node → what its credentials file holds
state map[string]BindingState
events []string
now time.Time
keys KeyPair
askDown bool
grants *memGrants
m *Manager
store *BusStore
usage *memLog
audit *memLog
licences *memState
bindings *memState
leases *memState
offered *memState
vendor *stubVendor
logins map[string]FullGrant // node → what its credentials file holds
events []string
now time.Time
keys KeyPair
askDown bool
}
func newMesh(t *testing.T) *miniMesh {
t.Helper()
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0}
mm := &miniMesh{logins: map[string]FullGrant{}, now: t0,
grants: &memGrants{held: map[string]heldGrant{}}}
now := func() time.Time { return mm.now }
mm.store = NewMemoryStore(now)
mm.usage, mm.audit = newMemLog(now), newMemLog(now)
mm.licences, mm.bindings = newMemState(now), newMemState(now)
mm.leases, mm.offered = newMemState(now), newMemState(now)
mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases,
OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now}
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
crypt, _ := NewCrypt("a key the vault made")
mm.keys, _ = GenerateKeyPair()
mm.m = &Manager{
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys,
Store: mm.store, Vendor: mm.vendor, Grants: mm.grants, Keys: mm.keys,
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
if mm.askDown {
return GrantAnswer{}, fmt.Errorf("503 no responders")
@@ -81,8 +144,6 @@ func newMesh(t *testing.T) *miniMesh {
box, err := Seal(string(raw), publicKey)
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
},
PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil },
DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil },
Emit: func(event string, body map[string]any) error {
raw, _ := json.Marshal(body)
mm.events = append(mm.events, event+" "+string(raw))
@@ -93,6 +154,15 @@ func newMesh(t *testing.T) *miniMesh {
return mm
}
// bound is a consumer's value in the state bindings, as its readers decode it.
func (mm *miniMesh) bound(consumer string) BindingState {
var b BindingState
if e, _ := mm.bindings.Get(consumer); e != nil {
_ = json.Unmarshal(e.Value, &b)
}
return b
}
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
@@ -129,15 +199,14 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
}
// A first binding follows the login: both nodes reported this account and were bound to nothing.
bs, _ := mm.store.Bindings(ctx)
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("bindings %v, state %v", bs, mm.state)
if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
t.Fatalf("bindings %v, state %v", bs, mm.bindings.values)
}
// The adoption is said, and so is each binding it made (novox/hq issue 625).
// The adoption is said, and so is each binding it made, once (novox/hq issue 625; design 39 bus-and-switch §21).
events := strings.Join(mm.events, "|")
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
if !strings.Contains(events, want) {
t.Fatalf("events lack %s: %v", want, mm.events)
for _, want := range []string{"licence.adopted ", `"consumer":"laptop"`, `"consumer":"server"`} {
if strings.Count(events, want) != 1 {
t.Fatalf("events say %s other than once: %v", want, mm.events)
}
}
}
@@ -169,6 +238,12 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
before, _ := mm.store.Licence(ctx, licence1)
// §2: a licence says where it was adopted from, and a newer login keeps the latest reading the key holds.
pct := 42.0
before.Usage = &UsageReading{SessionPct: &pct, ReadAt: t0.Format(time.RFC3339Nano)}
if before.AdoptedFrom != "laptop" || mm.store.SaveLicence(ctx, *before) != nil {
t.Fatalf("the adopted licence is %+v", before)
}
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
@@ -179,6 +254,9 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
}
if after.AdoptedFrom != "desktop" || after.Usage == nil || after.Usage.SessionPct == nil || *after.Usage.SessionPct != 42 {
t.Fatalf("after a newer login the licence is %+v", after)
}
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
t.Fatalf("one account became %d licences", len(ls))
}
@@ -201,11 +279,12 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
mm.now = t0.Add(5 * time.Hour)
second := *mm.m
second.Holder = "another run"
second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Grants: mm.m.Grants, Keys: mm.m.Keys,
AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run",
Settings: mm.m.Settings}
var wg sync.WaitGroup
results := make([]map[string]any, 2)
for i, m := range []*Manager{mm.m, &second} {
for i, m := range []*Manager{mm.m, second} {
wg.Add(1)
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
}
@@ -228,9 +307,9 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
g0 := mm.state["laptop"].Generation
g0 := mm.bound("laptop").Generation
_, _ = mm.m.Rotate(ctx, licence1, true)
g1 := mm.state["laptop"].Generation
g1 := mm.bound("laptop").Generation
if g1 <= g0 {
t.Fatalf("a rotation went from %d to %d", g0, g1)
}
@@ -242,43 +321,54 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
t.Fatal(err)
}
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
if mm.bound("laptop").Licence != "api" || mm.bound("laptop").Generation <= g1 {
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.bound("laptop"))
}
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
t.Fatal(err)
}
if _, ok := mm.state["laptop"]; ok {
if _, ok := mm.bindings.values["laptop"]; ok {
t.Fatal("a released consumer still has a binding in the state")
}
}
func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) {
// design 39 bus-and-switch §18, B7, B19: no answer of current holds a token; it names the licence's key and health.
func TestCurrentHoldsNoToken(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
node, _ := GenerateKeyPair()
answer, err := mm.m.Current(ctx, "laptop", node.PublicKey)
if err != nil || answer["kind"] != "subscription" {
answer, err := mm.m.Current(ctx, "laptop")
if err != nil || answer["kind"] != "subscription" || answer["key"] != KeyFor(licence1) || answer["health"] != "ok" ||
answer["accessExpiresAt"] == nil || answer["identity"] == nil {
t.Fatalf("%v %v", answer, err)
}
box := answer["sealed"].(SealedBox)
plain, err := Open(box, node.PrivateKey)
if err != nil {
t.Fatal(err)
raw, _ := json.Marshal(answer)
for _, token := range []string{"rt-", "at-", "sealed"} {
if strings.Contains(string(raw), token) {
t.Fatalf("current answered %s: %s", token, raw)
}
}
var handed map[string]any
_ = json.Unmarshal([]byte(plain), &handed)
if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil {
t.Fatalf("handed %v", handed)
l, _ := mm.store.Licence(ctx, licence1)
for failures, health := range map[int]string{0: "ok", 3: "failing"} {
l.Failures = failures
if got := Health(*l, mm.now, mm.m.Settings); got != health {
t.Errorf("%d failures: %s, want %s", failures, got, health)
}
}
other, _ := GenerateKeyPair()
if _, err := Open(box, other.PrivateKey); err == nil {
t.Fatal("the hand-over opened with another key")
l.Failures, l.RefreshExpiresAt = 0, mm.now.Add(48*time.Hour).UnixMilli()
if got := Health(*l, mm.now, mm.m.Settings); got != "expiring" {
t.Errorf("a refresh token expiring in two days: %s", got)
}
if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil {
if a, _ := mm.m.Current(ctx, "nobody"); a != nil {
t.Fatalf("a consumer bound to nothing was answered %v", a)
}
if bs, _ := mm.m.BindingsOf(ctx, "laptop"); len(bs) != 1 || bs[0].Consumer != "laptop" {
t.Fatalf("bindings of laptop: %v", bs)
}
// design 39 bus-and-switch B25: the verb bindings answers each binding's licence key beside the §3 fields.
if bs, _ := mm.m.BindingsOf(ctx, ""); len(bs) == 0 || bs[0].Key != KeyFor(bs[0].Licence) {
t.Fatalf("bindings answered %+v", bs)
}
}
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
@@ -290,15 +380,15 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
_ = mm.m.ReadUsage(ctx)
ls, _ := mm.m.Licences(ctx)
bs, _ := mm.store.Bindings(ctx)
everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs})
everything, _ := json.Marshal([]any{mm.events, mm.bindings.values, mm.licences.values, ls, bs})
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
if strings.Contains(string(everything), token) {
t.Fatalf("%s was published: %s", token, everything)
}
}
row, _ := mm.store.Licence(ctx, licence1)
if strings.Contains(row.Sealed, "rt-") {
t.Fatal("the grant is stored in the clear")
if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") {
t.Fatalf("the licence keeps a token: %s", raw)
}
}
@@ -324,22 +414,40 @@ func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing
}
}
func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
// The agent module's own report shape (claude-code's holdingsOf), parsed here.
raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription",
"refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},
"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0,
"changedAt":"2026-10-04T11:00:00.000Z"}`
var h Holdings
if err := json.Unmarshal([]byte(raw), &h); err != nil {
// design 39 bus-and-switch §23, §46, B12, B22: a holdings-changed, decoded from a copy of the agent module's form, is
// kept as its node's latest report, and one carrying a fingerprint the manager does not hold is a candidate; the
// manager asks every node with holdings.asked, its time and who asks.
func TestAHoldingsChangedIsHeardAndMakesACandidate(t *testing.T) {
mm := newMesh(t)
raw, err := os.ReadFile("testdata/holdings-changed.json")
if err != nil {
t.Fatal(err)
}
heard := &Heard{}
if _, err := heard.Take(raw); err != nil {
t.Fatal(err)
}
all := heard.All()
if len(all) != 1 || heard.Of("laptop") != 4 {
t.Fatalf("heard %+v", all)
}
h := all[0]
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
t.Fatalf("%+v", h)
}
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
t.Fatalf("the report's time does not parse: %v", err)
}
if c, err := mm.m.CandidatesIn(context.Background(), all); err != nil || len(c["u-1"]) != 1 {
t.Fatalf("candidates %v %v", c, err)
}
if _, err := heard.Take([]byte(`{"kind":"subscription"}`)); err == nil {
t.Fatal("a report naming no node was kept")
}
if err := mm.m.AskHoldings(Itself); err != nil || len(mm.events) != 1 ||
mm.events[0] != HoldingsAsked+` {"at":"`+t0.UTC().Format(time.RFC3339Nano)+`","by":"claude-licence-manager"}` {
t.Fatalf("said %v %v", mm.events, err)
}
}
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
@@ -350,15 +458,15 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
_, _ = mm.m.Consider(ctx, []Holdings{late})
if mm.state["laptop"].Licence != licence1 {
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state)
if mm.bound("laptop").Licence != licence1 {
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.bindings.values)
}
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
t.Fatal("the older login was exchanged")
}
g := mm.state["laptop"].Generation
g := mm.bound("laptop").Generation
_, _ = mm.m.Consider(ctx, []Holdings{late})
if mm.state["laptop"].Generation != g {
if mm.bound("laptop").Generation != g {
t.Fatal("a node already bound was bound again")
}
}
@@ -368,8 +476,8 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("%v", mm.state)
if mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
t.Fatalf("%v", mm.bindings.values)
}
mm.now = t0.Add(time.Hour)
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
@@ -379,11 +487,11 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
t.Fatalf("adopted %v", adopted)
}
if mm.state["laptop"].Licence != "acct-2@example.org" {
t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"])
if mm.bound("laptop").Licence != "acct-2@example.org" {
t.Fatalf("the node a login was made on stayed bound to %v", mm.bound("laptop"))
}
if mm.state["server"].Licence != licence1 {
t.Fatalf("another node moved: %v", mm.state["server"])
if mm.bound("server").Licence != licence1 {
t.Fatalf("another node moved: %v", mm.bound("server"))
}
ls, _ := mm.store.Licences(ctx)
if len(ls) != 2 {
@@ -396,11 +504,11 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
g := mm.state["laptop"]
g := mm.bound("laptop")
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1}
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
if mm.state["laptop"] != g {
t.Fatalf("a dead login moved its node: %v", mm.state["laptop"])
if mm.bound("laptop") != g {
t.Fatalf("a dead login moved its node: %v", mm.bound("laptop"))
}
}
@@ -413,9 +521,10 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
if err != nil || r["adopted"] != true {
t.Fatalf("%v %v", r, err)
}
l, _ := mm.store.Licence(ctx, "api")
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" {
t.Fatalf("stored %q", plain)
for _, part := range []string{PartGrant, PartAccess} {
if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" {
t.Fatalf("the vault holds %q as the %s", plain, part)
}
}
other, _ := GenerateKeyPair()
wrong, _ := Seal("sk", other.PublicKey)
@@ -439,7 +548,7 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
t.Fatal(err)
}
if g := mm.state["laptop"].Generation; g <= 16 {
if g := mm.bound("laptop").Generation; g <= 16 {
t.Fatalf("the laptop applied generation 16 and was given %d", g)
}
}
@@ -460,28 +569,68 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
}
}
// changedSince is the binding.changed events said from mark on, decoded.
func (mm *miniMesh) changedSince(mark int) []map[string]any {
var out []map[string]any
for _, e := range mm.events[mark:] {
if rest, ok := strings.CutPrefix(e, BindingChanged+"."); ok {
// The consumer is the event's further part (A-134); one said under another consumer's name reads as none.
consumer, body, _ := strings.Cut(rest, " ")
var v map[string]any
_ = json.Unmarshal([]byte(body), &v)
if v["consumer"] != consumer {
v["consumer"] = "said as binding.changed." + consumer
}
out = append(out, v)
}
}
return out
}
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. design 39
// bus-and-switch §21/AC1, §36/AC1, B7, B16: each says one binding.changed per consumer moved, with its licence's key,
// after its key is written or deleted; a switch says one licence.switched with §27's fields.
func TestEveryMoveSaysOneBindingChangedPerConsumer(t *testing.T) {
mm := newMesh(t)
var lines []string
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
ctx := context.Background()
once := func(mark int, consumer, licence, by string) map[string]any {
t.Helper()
got := mm.changedSince(mark)
if len(got) != 1 || got[0]["consumer"] != consumer || got[0]["licence"] != licence || got[0]["by"] != by {
t.Fatalf("binding.changed said %v, want one for %s to %q by %s", got, consumer, licence, by)
}
if licence != "" && got[0]["key"] != KeyFor(licence) {
t.Fatalf("binding.changed names the key %v", got[0]["key"])
}
if mm.bound(consumer).Licence != licence {
t.Fatalf("said before the key was written: %+v", mm.bound(consumer))
}
return got[0]
}
mark := len(mm.events)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
t.Fatalf("a login's binding said %+v", s)
}
once(mark, "laptop", licence1, Itself)
file := filepath.Join(t.TempDir(), "key")
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
t.Fatal(err)
}
mark = len(mm.events)
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
if e := once(mark, "laptop", "api", "g14.node-tools"); e["from"] != licence1 || e["kind"] != "api-key" || e["at"] == nil {
t.Fatalf("the switch said %v", e)
}
if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" {
t.Fatalf("the node is told %+v", s)
}
said := strings.Join(lines, "\n")
@@ -489,34 +638,46 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
t.Fatalf("the journal said:\n%s", said)
}
var switched string
var switched []string
for _, e := range mm.events {
if strings.HasPrefix(e, "licence.switched ") {
switched = e
switched = append(switched, e)
}
}
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
if !strings.Contains(switched, want) {
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
if len(switched) != 1 {
t.Fatalf("licence.switched said %d times: %v", len(switched), mm.events)
}
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"to":"api"`, `"consumers":["laptop"]`, `"why":"the switch verb"`} {
if !strings.Contains(switched[0], want) {
t.Fatalf("the event %q lacks %s", switched[0], want)
}
}
last := mm.store.Audits[len(mm.store.Audits)-1]
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
all := mm.audit.under("switched")
if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) {
t.Fatalf("the audit kept %s", raw)
}
_, _ = mm.m.Bind(ctx, "server", licence1)
mark = len(mm.events)
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
}
once(mark, "server", licence1, "novox.node-tools")
mark = len(mm.events)
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
t.Fatal(err)
}
if e := once(mark, "laptop", "", "person.operator"); e["from"] != "api" {
t.Fatalf("the release said %v", e)
}
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
t.Fatalf("the journal said:\n%s", said)
}
for _, e := range mm.events {
for _, gone := range []string{"licence.bound ", "licence.released ", "licence.rotated "} {
if strings.HasPrefix(e, gone) {
t.Fatalf("an event no longer said was said: %s", e)
}
}
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
t.Fatalf("an event carries a secret: %s", e)
}
@@ -0,0 +1,393 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"testing"
. "claude-licence-manager/internal/licences"
)
// design 39 bus-and-switch §1: the module moves to the new form whole — no `reads`, no old `state`, no `prepare`
// step — and asks for each state it keeps by template; its two logs are in the `logs` section with their bounds, the
// form the runtime on main issues (C3).
func TestTheManifestKeepsNoOldForm(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, old := range []string{"reads", "state"} {
if _, ok := m[old]; ok {
t.Errorf("the manifest keeps the old section %q", old)
}
}
var resources []struct {
ID string `json:"id"`
}
_ = json.Unmarshal(m["resources"], &resources)
for _, r := range resources {
if r.ID == "prepare" {
t.Error("the manifest keeps the run-once step prepare")
}
}
var requires []json.RawMessage
_ = json.Unmarshal(m["requires"], &requires)
kept := map[string]string{}
for _, r := range requires {
var e struct{ Name, Template string }
if json.Unmarshal(r, &e) == nil {
kept[e.Name] = e.Template
}
}
for name, template := range map[string]string{"licences": "state", "bindings": "state", "leases": "state",
"offered": "state", "usage": "", "audit": "", "carried": "state", "conditions": "state"} {
if kept[name] != template {
t.Errorf("bus-state entry %s is %q in requires, want %q", name, kept[name], template)
}
}
var logs []json.RawMessage
_ = json.Unmarshal(m["logs"], &logs)
declared := logsSection(logs)
for name, bounds := range map[string]map[string]float64{"usage": {"max-age-days": 35, "max-mib": 256},
"audit": {"max-mib": 64}} {
l, ok := declared[name]
if !ok {
t.Errorf("the log %s is not in the logs section", name)
continue
}
for k, v := range bounds {
if l[k] != v {
t.Errorf("the log %s has %s %v, want %v", name, k, l[k], v)
}
}
}
}
// issued is what the manager's own runtime is issued from its manifest (design 39 bus-and-switch C1): its account, the
// states it keeps (`requires` by template), the logs it keeps (the `logs` section, the form the runtime on main issues,
// C3; a log asked for by template in `requires` is issued nothing), what it may ask (`invokes`) and say (`emits`).
type issued struct {
RunsAs, SecretsOwner string
OwnSecrets map[string]string
Resources []map[string]any
States, Logs map[string]bool
Invokes, Emits map[string]bool
}
func manifestIssued(t testing.TB) issued {
t.Helper()
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
RunsAs string `json:"runs-as"`
SecretsOwner string `json:"secrets-owner"`
OwnSecrets map[string]string `json:"own-secrets"`
Requires []json.RawMessage `json:"requires"`
Logs []json.RawMessage `json:"logs"`
Resources []map[string]any `json:"resources"`
Invokes []string `json:"invokes"`
Emits []string `json:"emits"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
out := issued{RunsAs: m.RunsAs, SecretsOwner: m.SecretsOwner, OwnSecrets: m.OwnSecrets, Resources: m.Resources,
States: map[string]bool{}, Logs: map[string]bool{}, Invokes: map[string]bool{}, Emits: map[string]bool{}}
for _, r := range m.Requires {
var e struct{ Name, Storage string }
if json.Unmarshal(r, &e) != nil {
continue
}
if e.Storage == "key-value" {
out.States[e.Name] = true
}
}
for name := range logsSection(m.Logs) {
out.Logs[name] = true
}
for _, k := range m.Invokes {
out.Invokes[k] = true
}
for _, e := range m.Emits {
out.Emits[e] = true
}
return out
}
// logsSection is the manifest's `logs` section by name: each a name alone or {name, max-age-days, max-mib}.
func logsSection(logs []json.RawMessage) map[string]map[string]any {
out := map[string]map[string]any{}
for _, l := range logs {
var name string
if json.Unmarshal(l, &name) == nil {
out[name] = map[string]any{}
continue
}
var e map[string]any
if json.Unmarshal(l, &e) == nil {
if n, _ := e["name"].(string); n != "" {
out[n] = e
}
}
}
return out
}
// mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`<module>.<tool>@<node>`)
// is invoked as `<module>.<tool>`.
// emits is whether the manifest names the event key's family: the key itself, or a family it extends by further parts,
// as binding.changed.<consumer> extends binding.changed (design 47 G A-134).
func (i issued) emits(key string) bool {
for k := key; k != ""; {
if i.Emits[k] {
return true
}
dot := strings.LastIndex(k, ".")
if dot < 0 {
break
}
k = k[:dot]
}
return false
}
func (i issued) mayAsk(key string) bool {
k, _, _ := strings.Cut(key, "@")
return i.Invokes[k]
}
// design 39 bus-and-switch C1: the manager runs as an account of its own, which it makes and which owns what it places
// on its machine and its secrets, reaching the bus as itself; and that account is issued every ask its code makes,
// since its runtime is not the machine's tool runner.
func TestTheManagerRunsAsItsOwnAccount(t *testing.T) {
m := manifestIssued(t)
if m.RunsAs != Publisher {
t.Fatalf("the manager runs as %q, not as its own account %s", m.RunsAs, Publisher)
}
if m.SecretsOwner != Publisher || m.OwnSecrets["broker"] == "" {
t.Errorf("its secrets belong to %q and its own broker secret is %q: both are its account's", m.SecretsOwner,
m.OwnSecrets["broker"])
}
made := false
for _, r := range m.Resources {
switch r["type"] {
case "user":
made = made || r["name"] == Publisher
case "directory", "file":
if r["owner"] != Publisher {
t.Errorf("resource %v is owned by %v, not by the manager's account", r["id"], r["owner"])
}
}
}
if !made {
t.Errorf("the manager does not make its account: no user resource named %s", Publisher)
}
files, _ := filepath.Glob("../*/*.go")
shared, _ := filepath.Glob("../../internal/*/*.go")
files = append(files, shared...)
asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`)
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, k := range asked.FindAllStringSubmatch(string(src), -1) {
if !m.mayAsk(k[1]) {
t.Errorf("%s asks %s, which the manifest's invokes do not name", f, k[1])
}
}
}
}
// design 39 parts §9, §10: the carry-over has left the module whole. Its manifest requires neither the store database
// nor the secret grant-key, contributes and binds nothing and has no secrets section; no word of its code names the
// database's address or grant.key; its go.mod requires no postgres driver; the tool licences_carried is gone, and the
// state carried stays as the carry-over's record.
func TestTheCarryOverHasLeft(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, gone := range []string{"contributes", "binds", "secrets"} {
if _, ok := m[gone]; ok {
t.Errorf("the manifest keeps %q", gone)
}
}
var requires []json.RawMessage
_ = json.Unmarshal(m["requires"], &requires)
carried := false
for _, r := range requires {
var bare string
if json.Unmarshal(r, &bare) == nil {
t.Errorf("the manifest requires %q", bare)
}
var e struct{ Name string }
if json.Unmarshal(r, &e) == nil && e.Name == "carried" {
carried = true
}
}
if !carried {
t.Error("the state carried, the carry-over's record, is gone")
}
var tools []string
_ = json.Unmarshal(m["tools"], &tools)
if slices.Contains(tools, "licences_carried") {
t.Error("the manifest keeps the tool licences_carried")
}
var resources []struct{ ID string }
_ = json.Unmarshal(m["resources"], &resources)
for _, r := range resources {
if r.ID == "database-url" {
t.Error("the manifest keeps the resource database-url")
}
}
words := []string{"DATABASE_URL" + "_FILE", "MESH_LICENCE_KEY" + "_FILE"}
err = filepath.WalkDir(moduleDir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
switch filepath.Ext(path) {
case ".go", ".json", ".mod", ".sum":
default:
if filepath.Base(filepath.Dir(path)) != "files" {
return nil // a built binary is no code of the module
}
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
for _, w := range words {
if strings.Contains(string(src), w) {
t.Errorf("%s names %s", path, w)
}
}
if filepath.Base(path) == "go.mod" && strings.Contains(string(src), "github.com/jackc/pgx") {
t.Errorf("%s requires the postgres driver", path)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
// design 39 parts §1 to §4, §6 to §8: the module is four parts in one manifest. Its artifacts are exactly the manager
// bundle, the tools bundle and the templates archive, with their words; its resources exactly the account, its
// directory and the settings file, in that order; no process or container resource, no run-once step, no bus account
// of its own and no runtime-health; its tools are the four reading ones and the three acting ones; files/ holds only
// the settings template.
func TestTheModuleIsFourParts(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
same := func(what string, got, want any) {
t.Helper()
g, _ := json.Marshal(got)
w, _ := json.Marshal(want)
if string(g) != string(w) {
t.Errorf("%s is %s, want %s", what, g, w)
}
}
build, _ := m["build"].(map[string]any)
same("build.artifacts", build["artifacts"], []map[string]any{
{"name": "manager", "kind": "bundle", "language": "go", "system": "arch", "from": "cmd/manager",
"binary": "claude-licence-manager", "loads": []string{"claude-licence-manager"},
"env": map[string]string{"MESH_LICENCE_STATE": "${dir:state}", "MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"}},
{"name": "tools", "kind": "bundle", "language": "go", "system": "arch", "from": "cmd/tools",
"binary": "claude-licence-manager-tools", "loads": []string{"claude-licence-manager-tools"}},
{"name": "files", "kind": "archive", "from": "files"},
})
same("resources", m["resources"], []map[string]any{
{"id": "account", "type": "user", "name": Publisher, "shell": "/usr/bin/nologin", "home": "/var/lib/" + Publisher},
{"id": "state", "type": "directory", "mode": "0700", "owner": Publisher, "place": ".", "unknown": "program"},
{"id": "settings", "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", "owner": Publisher,
"takes-config": []string{"cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours",
"refresh_warn_days", "switch_auto", "switch_at_pct", "room_below_pct", "warn_at_pct", "usage_minutes",
"usage_stale_minutes"}, "merge": "json", "template": "files/settings.json"},
})
tools, _ := m["tools"].([]any)
var names []string
for _, n := range tools {
names = append(names, fmt.Sprint(n))
}
sort.Strings(names)
same("tools", names, []string{"licences_conditions", "licences_kept", "licences_read_usage", "licences_resync",
"licences_settings", "licences_switch_now", "licences_switches"})
same("runs-as", m["runs-as"], Publisher)
same("secrets-owner", m["secrets-owner"], Publisher)
same("own-secrets", m["own-secrets"], map[string]string{"broker": "${dir:state}/broker"})
if _, ok := m["runtime-health"]; ok {
t.Error("the manifest adds runtime-health: the node-engine's judgement that the runtime is alive is its health")
}
var walk func(path string, v any)
walk = func(path string, v any) {
switch v := v.(type) {
case map[string]any:
for k, e := range v {
if k == "bus" || k == "run-once" {
t.Errorf("the manifest says %s at %s", k, path)
}
if k == "type" && (e == "process" || e == "container") {
t.Errorf("the manifest has a %v resource at %s", e, path)
}
walk(path+"."+k, e)
}
case []any:
for i, e := range v {
walk(fmt.Sprintf("%s[%d]", path, i), e)
}
}
}
walk("", m)
files, _ := os.ReadDir(filepath.Join(moduleDir, "files"))
var templates []string
for _, f := range files {
templates = append(templates, f.Name())
}
same("files/", templates, []string{"settings.json"})
}
// design 39 parts §2, §4: the manager bundle registers the seat's ten verbs under the seat and the three acting tools
// under the module, and nothing more.
func TestTheManagerRegistersTheVerbsAndTheActs(t *testing.T) {
var names []string
for _, tool := range tools() {
names = append(names, tool.Name)
}
sort.Strings(names)
var want []string
for _, v := range []string{"licences", "bindings", "bind", "switch", "release", "refresh", "usage", "adopt",
"current", "public-key"} {
want = append(want, Seat+"."+v)
}
want = append(want, "licences_read_usage", "licences_resync", "licences_switch_now")
sort.Strings(want)
if !slices.Equal(names, want) {
t.Fatalf("the manager bundle registers %v, not %v", names, want)
}
}
@@ -0,0 +1,27 @@
package main
// A licence's room (design 39 bus-and-switch §25, §48, C4): 100 less its weekly percentage in its latest reading, the
// session window taking no part (the vendor retired the session limit), known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known,
// so it is never a target.
import (
"time"
. "claude-licence-manager/internal/licences"
)
// Room is a licence's room at now, and whether it is known.
func Room(l Licence, now time.Time, s Settings) (float64, bool) {
if l.Kind != "subscription" || l.Usage == nil || l.Usage.WeeklyPct == nil {
return 0, false
}
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
if err != nil || now.Sub(read) > s.UsageStale {
return 0, false
}
if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) {
return 0, false
}
return 100 - *l.Usage.WeeklyPct, true
}
@@ -0,0 +1,132 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
. "claude-licence-manager/internal/licences"
)
// vendorUsage is the vendor's usage answer as it reads: three windows, each with its reset time.
var vendorUsage = map[string]any{
"five_hour": map[string]any{"utilization": 41.0, "resets_at": "2026-11-11T23:00:00Z"},
"seven_day": map[string]any{"utilization": 97.0, "resets_at": "2026-11-14T09:00:00Z"},
"seven_day_sonnet": map[string]any{"utilization": 12.0, "resets_at": "2026-11-15T09:00:00Z"},
}
// design 39 bus-and-switch §24/AC1: a vendor answer is flattened with its three reset times, kept beside the licence
// with when it was read, and said as usage.read with those fields.
func TestAReadingIsFlattenedWithThreeResets(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
mm.vendor.usage = vendorUsage
mm.events = nil
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
l, _ := mm.store.Licence(ctx, licence1)
u := l.Usage
if u == nil || *u.SessionPct != 41 || u.SessionResetsAt != "2026-11-11T23:00:00Z" || *u.WeeklyPct != 97 ||
u.WeeklyResetsAt != "2026-11-14T09:00:00Z" || *u.SonnetPct != 12 || u.SonnetResetsAt != "2026-11-15T09:00:00Z" ||
u.ReadAt != t0.UTC().Format(time.RFC3339Nano) || u.ReadError != "" {
t.Fatalf("kept %+v", u)
}
said := strings.Join(mm.events, "\n")
for _, want := range []string{`"weeklyResetsAt":"2026-11-14T09:00:00Z"`, `"sonnetResetsAt":"2026-11-15T09:00:00Z"`,
`"sessionResetsAt":"2026-11-11T23:00:00Z"`} {
if !strings.Contains(said, "usage.read ") || !strings.Contains(said, want) {
t.Fatalf("said %s, lacking %s", said, want)
}
}
}
// design 39 bus-and-switch §24/AC2: a failed answer keeps the last percentages and their time, writes the vendor's
// status and reason, and appends no reading.
func TestAFailedReadingKeepsThePercentages(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
mm.vendor.usage = vendorUsage
_ = mm.m.ReadUsage(ctx)
mm.now = t0.Add(5 * time.Minute)
mm.vendor.usageErr = errors.New(`the usage endpoint answered 429: {"error":"rate_limited"}`)
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
l, _ := mm.store.Licence(ctx, licence1)
u := l.Usage
if u == nil || *u.WeeklyPct != 97 || u.ReadAt != t0.UTC().Format(time.RFC3339Nano) ||
u.ReadError != `the usage endpoint answered 429: {"error":"rate_limited"}` {
t.Fatalf("kept %+v", u)
}
if n := len(mm.usage.entries); n != 1 {
t.Fatalf("a failed reading appended: %d entries", n)
}
// §16, §31: a grant the vault does not answer is a failed reading, written and gone past, never the end of the round.
mm.vendor.usageErr = nil
mm.grants.mu.Lock()
delete(mm.grants.held, SecretName(KeyFor(licence1), PartGrant))
mm.grants.mu.Unlock()
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatalf("a grant the vault did not answer ended the round: %v", err)
}
l, _ = mm.store.Licence(ctx, licence1)
if !strings.Contains(l.Usage.ReadError, "cannot be read from the vault") || *l.Usage.WeeklyPct != 97 {
t.Fatalf("after the vault's refusal the reading is %+v", l.Usage)
}
// §4, §16: a reading is taken under the licence's lease; with another run holding it, nothing is read or written.
if ok, _ := mm.store.Lease(ctx, "licence."+KeyFor(licence1), "another run", time.Minute); !ok {
t.Fatal("the lease was not taken")
}
reads, err := mm.m.ReadUsageOf(ctx, licence1, true)
if err != nil || len(reads) != 1 || reads[0].Error != "another run holds its lease" {
t.Fatalf("a reading under another's lease answered %+v, %v", reads, err)
}
if again, _ := mm.store.Licence(ctx, licence1); again.Usage.ReadError != l.Usage.ReadError {
t.Fatalf("a reading under another's lease wrote %+v", again.Usage)
}
}
// design 39 bus-and-switch §25/AC1, §48, C4: a table of readings, ages and healths and the room each gives.
func TestTheRoomOfEachLicence(t *testing.T) {
now := t0
pct := func(v float64) *float64 { return &v }
read := func(session, weekly float64, ago time.Duration) *UsageReading {
return &UsageReading{SessionPct: pct(session), WeeklyPct: pct(weekly), ReadAt: now.Add(-ago).Format(time.RFC3339Nano)}
}
for _, c := range []struct {
name string
l Licence
room float64
known bool
}{
{"the weekly window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
{"the session takes no part (C4)", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 80, true},
{"no session window, weekly 33 (C4)", Licence{Kind: "subscription", Usage: &UsageReading{WeeklyPct: pct(33),
ReadAt: now.Format(time.RFC3339Nano)}}, 67, true},
{"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true},
{"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false},
{"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading {
u := read(10, 30, 5*time.Minute)
u.ReadError = "the usage endpoint answered 500: x"
return u
}()}, 70, true},
{"never read", Licence{Kind: "subscription"}, 0, false},
{"no weekly window", Licence{Kind: "subscription", Usage: &UsageReading{SessionPct: pct(10),
ReadAt: now.Format(time.RFC3339Nano)}}, 0, false},
{"refresh failing", Licence{Kind: "subscription", Failures: 3, Usage: read(10, 10, time.Minute)}, 0, false},
{"refresh failed twice", Licence{Kind: "subscription", Failures: 2, Usage: read(10, 10, time.Minute)}, 90, true},
{"refresh token expired", Licence{Kind: "subscription", RefreshExpiresAt: now.UnixMilli(),
Usage: read(10, 10, time.Minute)}, 0, false},
{"an API key", Licence{Kind: "api-key", Usage: read(0, 0, 0)}, 0, false},
} {
room, known := Room(c.l, now, Defaults)
if room != c.room || known != c.known {
t.Errorf("%s: room %v known %v, want %v %v", c.name, room, known, c.room, c.known)
}
}
}
@@ -0,0 +1,72 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// A box the agent module's TypeScript sealed opens here: the two implementations are one format.
func TestABoxSealedInTypeScriptOpensInGo(t *testing.T) {
raw, err := os.ReadFile("testdata/sealed-by-typescript.json")
if err != nil {
t.Fatal(err)
}
var f struct {
PrivateKey string `json:"privateKey"`
Box SealedBox `json:"box"`
Plaintext string `json:"plaintext"`
}
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
got, err := Open(f.Box, f.PrivateKey)
if err != nil || got != f.Plaintext {
t.Fatalf("opened %q, %v", got, err)
}
}
// What Go seals opens with its own key and no other.
func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
a, _ := GenerateKeyPair()
b, _ := GenerateKeyPair()
box, err := Seal("a token", a.PublicKey)
if err != nil {
t.Fatal(err)
}
if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" {
t.Fatalf("opened %q, %v", got, err)
}
if _, err := Open(box, b.PrivateKey); err == nil {
t.Fatal("a box opened for another key")
}
}
// design 39 bus-and-switch C2: a key pair the manager finds and cannot read (left by another account, here mode 000) is
// replaced in its place by a new one, said in its journal; the new one is read back by the next start.
func TestAKeyPairItCannotReadIsMadeAnew(t *testing.T) {
if os.Geteuid() == 0 {
t.Fatal("run as root, a file of mode 000 is still read: this test needs an ordinary account")
}
dir := t.TempDir()
file := filepath.Join(dir, "manager-key.json")
if err := os.WriteFile(file, []byte(`{"publicKey":"old","privateKey":"old"}`), 0o000); err != nil {
t.Fatal(err)
}
var said []string
log := func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }
k, err := keypair(dir, log)
if err != nil {
t.Fatalf("a key pair it cannot read stops the manager: %v", err)
}
if k.PublicKey == "" || k.PublicKey == "old" || len(said) != 1 || !strings.Contains(said[0], "manager-key.json") {
t.Fatalf("not made anew and said: key %q, journal %q", k.PublicKey, said)
}
again, err := keypair(dir, log)
if err != nil || again != k || len(said) != 1 {
t.Fatalf("the new pair is not the one read at the next start: %v, %v, %q", again == k, err, said)
}
}
@@ -0,0 +1,45 @@
package main
// The manager's settings (design 39 §8, design 39 bus-and-switch §33): today's five and the six of the switch, each
// declared in the manifest's `config` with its default and bound, set through mesh-config and written into the
// settings file. They are read again at every reading, so a change takes effect at the next one, and each change of a
// value read is written in the audit under `settings`.
import (
"context"
"fmt"
"reflect"
. "claude-licence-manager/internal/licences"
)
// settings are those in force.
func (m *Manager) settings() Settings {
m.settingsMu.RLock()
defer m.settingsMu.RUnlock()
return m.Settings
}
// ReadSettings reads the settings file again, at every reading (§33): each value that changed is written in the audit
// under `settings`, its value before and now, and the settings read are those in force from then on.
func (m *Manager) ReadSettings(ctx context.Context) error {
if m.SettingsFile == "" {
return nil
}
next := SettingsFrom(m.SettingsFile)
m.settingsMu.Lock()
before := m.Settings
m.Settings = next
m.settingsMu.Unlock()
was, now := before.Values(), next.Values()
changed := map[string]any{}
for k, v := range now {
if !reflect.DeepEqual(was[k], v) {
changed[k] = map[string]any{"from": was[k], "to": v}
}
}
if len(changed) == 0 {
return nil
}
return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed)
}
@@ -0,0 +1,33 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
)
// design 39 bus-and-switch §33: the settings are read again at every reading, and each change of a value read is
// written once in the audit under settings.
func TestASettingChangedIsReadAtTheNextReadingAndAudited(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
mm.m.SettingsFile = filepath.Join(t.TempDir(), "settings.json")
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 99}`), 0o600)
if err := mm.m.ReadUsage(ctx); err != nil || len(mm.audit.under("settings")) != 0 {
t.Fatalf("an unchanged file audited %v (%v)", mm.audit.under("settings"), err)
}
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 95, "usage_minutes": 10}`), 0o600)
_ = mm.m.ReadUsage(ctx)
_ = mm.m.ReadUsage(ctx)
if s := mm.m.settings(); s.SwitchAt != 95 || s.UsageEvery.Minutes() != 10 {
t.Fatalf("in force %+v", s)
}
got := mm.audit.under("settings")
if len(got) != 1 {
t.Fatalf("audited %v", got)
}
if c, _ := got[0]["switch_at_pct"].(map[string]any); c["from"] != 99.0 || c["to"] != 95.0 {
t.Fatalf("audited %v", got[0])
}
}
@@ -0,0 +1,76 @@
package main
// The manager's store (novox/hq ADR 0183, design 39 bus-and-switch §2 to §7): licences, bindings with a generation,
// leases, what became of each login it was offered, usage readings and the audit, all in the bus's own storage
// (busstore.go). Every rule is tested on the same store over a bus kept in memory.
import (
"context"
"time"
. "claude-licence-manager/internal/licences"
)
// Binding is one consumer's binding (ADR 0206, §3): the licence, kind and generation it should hold, since when, and
// why and by whom it was moved last.
type Binding struct {
Consumer string `json:"consumer"`
Licence string `json:"licence"`
// Key is the licence's key (§10), answered by the verb bindings (B25); the store keeps none.
Key string `json:"key,omitempty"`
Kind string `json:"kind,omitempty"`
Generation int64 `json:"generation"`
Since string `json:"since,omitempty"`
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Move is what moves a binding: the licence's kind, why and by whom, and the floor a consumer's generation must pass
// (B11: the generation its node last reported); a nil Floor is none.
type Move struct {
Kind, Why, By string
Floor func(consumer string) int64
}
// floor is the floor m gives consumer.
func (m Move) floor(consumer string) int64 {
if m.Floor == nil {
return 0
}
return m.Floor(consumer)
}
// UsageRow is one usage reading.
type UsageRow struct {
Licence string `json:"licence"`
At int64 `json:"at"`
Reading UsageReading `json:"reading"`
// Raw is the vendor's answer whole, answered only when asked (§35).
Raw map[string]any `json:"raw,omitempty"`
}
// Store is what the manager keeps.
type Store interface {
Licences(ctx context.Context) ([]Licence, error)
Licence(ctx context.Context, name string) (*Licence, error)
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
SaveLicence(ctx context.Context, l Licence) error
Bindings(ctx context.Context) ([]Binding, error)
Binding(ctx context.Context, consumer string) (*Binding, error)
// Bind binds (or switches) a consumer at its next generation.
Bind(ctx context.Context, consumer, licence string, m Move) (Binding, error)
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence its next generation: what a rotation is to them.
Advance(ctx context.Context, licence string, m Move) ([]Binding, error)
// Lease takes a lease for d, or answers false while another holder's is live.
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
Unlease(ctx context.Context, key, holder string) error
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
// Usage is the latest readings, newest first, of one licence or every one: at most limit, none before since (zero
// is none), the vendor's answer with raw (§35).
Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error)
// Audit appends one entry for an act; an error is the act failing (B20).
Audit(ctx context.Context, what string, detail map[string]any) error
}
@@ -0,0 +1,21 @@
package main
import (
"testing"
)
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
in := int64(60)
kept := NextGrant(prev, tokenResponse{AccessToken: "a2", ExpiresIn: &in}, 1000)
if !kept.OK || kept.Grant.RefreshToken != "r" || kept.Grant.ExpiresAt != 61_000 {
t.Fatalf("%+v", kept)
}
rotated := NextGrant(prev, tokenResponse{AccessToken: "a3", RefreshToken: "r2"}, 0)
if rotated.Grant.RefreshToken != "r2" {
t.Fatalf("%+v", rotated)
}
if NextGrant(prev, tokenResponse{}, 0).OK {
t.Fatal("an answer with no access token was a grant")
}
}
@@ -0,0 +1,183 @@
package main
// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and
// L, with one or more consumers bound to it, is at or above switch_at_pct in its weekly window (C4: the session window takes
// no part), every consumer bound to L moves, in one act, to the other licence with known room whose weekly window is
// below room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off
// a licence at its threshold, so none flaps between two.
import (
"context"
"fmt"
"sort"
"strings"
. "claude-licence-manager/internal/licences"
)
// ByUsage is who moves a consumer by the rule (§26).
const ByUsage = "usage"
// Switch is one act of the rule: from L to the target, the consumers it moves, the window of L that reached its
// threshold, its percentage and reset, and the target's room.
type Switch struct {
From string `json:"from"`
To string `json:"to"`
Consumers []string `json:"consumers"`
Window string `json:"window"`
Pct float64 `json:"pct"`
ResetsAt string `json:"resetsAt"`
Room float64 `json:"room"`
}
// why is the cause a moved binding carries: L, the window, its percentage and its reset (§26).
func (s Switch) why() string {
return fmt.Sprintf("%s reached %g %% of its %s window, which resets at %s", s.From, s.Pct, s.Window, s.ResetsAt)
}
// fields are the act's fields, the same in licence.switched and the audit entry switched (§27).
func (s Switch) fields() map[string]any {
f := switchedFields(s.From, s.To, s.Consumers, Cause{Why: s.why(), By: ByUsage})
f["window"], f["pct"], f["resetsAt"], f["room"] = s.Window, s.Pct, s.ResetsAt, s.Room
return f
}
// Rule is what the rule finds for one licence at its threshold: the act, or none with NoTarget when no other licence
// has room; Over is then the licence's consumers and the window at its threshold, for the condition no-room (§29).
type Rule struct {
Switch *Switch
NoTarget bool
Over *Switch
}
// consumersOf are the consumers bound to licence, sorted.
func consumersOf(licence string, bindings []Binding) []string {
var consumers []string
for _, b := range bindings {
if b.Licence == licence {
consumers = append(consumers, b.Consumer)
}
}
sort.Strings(consumers)
return consumers
}
// windowAt is l's weekly window when it is at or above pct (C4: the session window takes no part): its name,
// percentage and reset; ok false when it is not.
func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) {
if u == nil || u.WeeklyPct == nil || *u.WeeklyPct < pct {
return "", 0, "", false
}
return "weekly", *u.WeeklyPct, u.WeeklyResetsAt, true
}
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose weekly window is below
// room_below_pct (C4), the one with the most room, the first by name on a tie; empty when none has room.
func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) {
others := append([]Licence(nil), licences...)
sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name })
to, best := "", -1.0
for _, o := range others {
if o.Name == l.Name {
continue
}
room, known := Room(o, m.Now(), s)
if !known || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
continue
}
best, to = room, o.Name
}
return to, best
}
// RuleFor is what §26 gives for licence l among every licence and binding, under the settings s. A licence below its
// threshold, or with no consumer, gives nothing.
func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s Settings) Rule {
consumers := consumersOf(l.Name, bindings)
if len(consumers) == 0 {
return Rule{}
}
sw := Switch{From: l.Name, Consumers: consumers}
var ok bool
if sw.Window, sw.Pct, sw.ResetsAt, ok = windowAt(l.Usage, s.SwitchAt); !ok {
return Rule{}
}
if sw.To, sw.Room = m.targetFor(l, licences, s); sw.To == "" {
sw.Room = 0
return Rule{NoTarget: true, Over: &sw}
}
return Rule{Switch: &sw}
}
// ApplyRule applies §26 to one licence, or to every one when licence is empty: after a reading, by switch_auto, or by
// hand, even with switch_auto off (§32, §39). With dry it answers the moves and moves nothing. An invalid pair of
// settings moves no consumer (§33).
func (m *Manager) ApplyRule(ctx context.Context, licence string, byHand, dry bool) ([]Switch, error) {
s := m.settings()
if (!s.SwitchAuto && !byHand) || len(s.Invalid()) > 0 {
return nil, nil
}
licences, err := m.Store.Licences(ctx)
if err != nil {
return nil, err
}
bindings, err := m.Store.Bindings(ctx)
if err != nil {
return nil, err
}
var acts []Switch
for _, l := range licences {
if licence != "" && l.Name != licence {
continue
}
r := m.RuleFor(l, licences, bindings, s)
if r.Switch == nil {
continue
}
acts = append(acts, *r.Switch)
if dry {
continue
}
if err := m.act(ctx, *r.Switch, licences); err != nil {
return acts, err
}
// What was moved is bound to the target now, for the next licence's rule.
if bindings, err = m.Store.Bindings(ctx); err != nil {
return acts, err
}
}
return acts, nil
}
// act moves every consumer of one act as the switch verb does, then says the act once and tells the operator (§26 to
// §28).
func (m *Manager) act(ctx context.Context, sw Switch, licences []Licence) error {
kind := ""
for _, l := range licences {
if l.Name == sw.To {
kind = l.Kind
}
}
floor := m.floorIn(nil)
for _, consumer := range sw.Consumers {
b, err := m.Store.Bind(ctx, consumer, sw.To, Move{Kind: kind, Why: sw.why(), By: ByUsage, Floor: floor})
if err != nil {
return fmt.Errorf("moving %s to %s: %w", consumer, sw.To, err)
}
m.changed(b, sw.From)
}
f := sw.fields()
if err := m.record(ctx, "switched", fmt.Sprintf("switched %s from %s to %s: %s; by %s",
strings.Join(sw.Consumers, ", "), sw.From, sw.To, sw.why(), ByUsage), f); err != nil {
return err
}
_ = m.Emit("licence.switched", f)
if m.Tell != nil {
line := fmt.Sprintf("%s; %s has %g %% room", sw.why(), sw.To, sw.Room)
if err := m.Tell(ctx, strings.Join(sw.Consumers, ", ")+" moved to "+sw.To, []string{line}); err != nil {
m.Log("the switch of %s to %s could not be told: %v", strings.Join(sw.Consumers, ", "), sw.To, err)
}
}
return nil
}
@@ -0,0 +1,194 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
. "claude-licence-manager/internal/licences"
)
// read is a licence's latest reading, read at t0.
func read(session, weekly float64) *UsageReading {
return &UsageReading{SessionPct: &session, SessionResetsAt: "2026-11-11T23:00:00Z", WeeklyPct: &weekly,
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
}
// weekly is a reading with no session window, as the vendor answers since the session limit was retired (C4).
func weekly(pct float64) *UsageReading {
u := read(0, pct)
u.SessionPct, u.SessionResetsAt = nil, ""
return u
}
func sub(name string, u *UsageReading) Licence {
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
}
// design 39 bus-and-switch §26/AC1, §25, §48, C4: a table of licences, readings and bindings and the moves each gives.
func TestTheRuleMovesEachTable(t *testing.T) {
mm := newMesh(t)
on := func(consumers ...string) []Binding {
var out []Binding
for _, c := range consumers {
out = append(out, Binding{Consumer: c, Licence: "a"})
}
return out
}
for _, c := range []struct {
name string
licences []Licence
bindings []Binding
to string
window string
noTarget bool
consumers int
}{
{"below its threshold", []Licence{sub("a", read(50, 98)), sub("b", read(0, 0))}, on("x"), "", "", false, 0},
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
on("x", "y", "z", "w"), "c", "weekly", false, 4},
{"the session window at 100 moves nothing (C4)", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))},
on("x"), "", "", false, 0},
{"no session window, weekly 99, to weekly 33 (C4)", []Licence{sub("a", weekly(99)), sub("b", weekly(33))},
on("x", "y"), "b", "weekly", false, 2},
{"a tie goes to the first by name", []Licence{sub("a", read(0, 99)), sub("c", read(10, 10)), sub("b", read(10, 10))},
on("x"), "b", "weekly", false, 1},
{"a target's weekly below room_below_pct, its session not counted (C4)", []Licence{sub("a", read(0, 99)),
sub("b", read(0, 90)), sub("c", read(95, 89))}, on("x"), "c", "weekly", false, 1},
{"no other licence has room", []Licence{sub("a", read(0, 99)), sub("b", read(0, 95)),
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
} {
r := mm.m.RuleFor(c.licences[0], c.licences, c.bindings, Defaults)
to, window, n := "", "", 0
if r.Switch != nil {
to, window, n = r.Switch.To, r.Switch.Window, len(r.Switch.Consumers)
}
if to != c.to || window != c.window || n != c.consumers || r.NoTarget != c.noTarget {
t.Errorf("%s: to %q window %q consumers %d no target %v", c.name, to, window, n, r.NoTarget)
}
}
}
// fourOnA is four consumers bound to a, at 99 % of its weekly window, b with 70 % room and c with 60 %.
func fourOnA(t *testing.T) *miniMesh {
mm := newMesh(t)
ctx := context.Background()
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 30)), sub("c", read(40, 40))} {
_ = mm.store.SaveLicence(ctx, l)
}
for _, c := range []string{"ace", "g14", "laptop", "server"} {
_, _ = mm.store.Bind(ctx, c, "a", Move{Kind: "subscription", Why: "set", By: "test"})
}
mm.events = nil
return mm
}
// design 39 bus-and-switch §27/AC1, §26, §21: one act says one licence.switched with the consumers moved, the window,
// its percentage, reset and the target's room; one binding.changed per consumer; one audit entry switched with the same
// fields; each binding moved by usage, its why naming the licence, the window, the percentage and the reset.
func TestOneSwitchedPerAct(t *testing.T) {
mm := fourOnA(t)
acts, err := mm.m.ApplyRule(context.Background(), "", false, false)
if err != nil || len(acts) != 1 || acts[0].To != "b" || acts[0].Room != 70 {
t.Fatalf("acts %+v %v", acts, err)
}
var switched []map[string]any
changed := 0
for _, e := range mm.events {
name, body, _ := strings.Cut(e, " ")
var f map[string]any
_ = json.Unmarshal([]byte(body), &f)
switch name {
case "licence.switched":
switched = append(switched, f)
case "binding.changed." + fmt.Sprint(f["consumer"]):
changed++
}
}
if len(switched) != 1 || changed != 4 {
t.Fatalf("said %v", mm.events)
}
s := switched[0]
if s["from"] != "a" || s["to"] != "b" || len(s["consumers"].([]any)) != 4 || s["by"] != ByUsage ||
s["window"] != "weekly" || s["pct"] != 99.0 || s["resetsAt"] != "2026-11-14T09:00:00Z" || s["room"] != 70.0 {
t.Fatalf("licence.switched %v", s)
}
audit := mm.audit.under("switched")
if len(audit) != 1 || audit[0]["window"] != "weekly" || len(audit[0]["consumers"].([]any)) != 4 {
t.Fatalf("audit %v", audit)
}
b := mm.bound("laptop")
if b.Licence != "b" || b.By != ByUsage || b.Why != "a reached 99 % of its weekly window, which resets at 2026-11-14T09:00:00Z" {
t.Fatalf("laptop %+v", b)
}
}
// design 39 bus-and-switch §28/AC1: the rule tells the operator once per act, and the switch verb never.
func TestTheRuleTellsOnceAndTheVerbNever(t *testing.T) {
mm := fourOnA(t)
var told []string
mm.m.Tell = func(_ context.Context, headline string, lines []string) error {
told = append(told, headline+" | "+strings.Join(lines, " "))
return nil
}
_, _ = mm.m.ApplyRule(context.Background(), "", false, false)
if len(told) != 1 || told[0] != "ace, g14, laptop, server moved to b | a reached 99 % of its weekly window, "+
"which resets at 2026-11-14T09:00:00Z; b has 70 % room" {
t.Fatalf("told %v", told)
}
_, _ = mm.m.Bind(WithCause(context.Background(), Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
if len(told) != 1 {
t.Fatalf("the switch verb told %v", told)
}
}
// design 39 bus-and-switch §32/AC1, §33/AC2: dry answers the same moves as the act and moves nothing; an invalid pair
// of settings, or switch_auto off, moves nothing after a reading, and the rule by hand runs with switch_auto off.
func TestDryAnswersTheSameMovesAndMovesNothing(t *testing.T) {
ctx := context.Background()
mm := fourOnA(t)
dry, err := mm.m.ApplyRule(ctx, "a", true, true)
if err != nil || len(dry) != 1 || mm.bound("ace").Licence != "a" || len(mm.events) != 0 {
t.Fatalf("dry %+v %v, said %v", dry, err, mm.events)
}
acts, _ := mm.m.ApplyRule(ctx, "a", true, false)
if len(acts) != 1 || acts[0].To != dry[0].To || strings.Join(acts[0].Consumers, ",") != strings.Join(dry[0].Consumers, ",") {
t.Fatalf("dry %+v, act %+v", dry, acts)
}
for name, change := range map[string]func(*Settings){
"an invalid pair": func(s *Settings) { s.RoomBelow = 100 },
"switch_auto off": func(s *Settings) { s.SwitchAuto = false },
} {
mm := fourOnA(t)
change(&mm.m.Settings)
if acts, _ := mm.m.ApplyRule(ctx, "", false, false); len(acts) != 0 || mm.bound("ace").Licence != "a" {
t.Errorf("%s moved %+v", name, acts)
}
}
mm = fourOnA(t)
mm.m.Settings.SwitchAuto = false
if acts, _ := mm.m.ApplyRule(ctx, "", true, false); len(acts) != 1 || mm.bound("ace").Licence != "b" {
t.Fatalf("by hand with switch_auto off moved %+v", acts)
}
}
// design 39 bus-and-switch §26: a reading at the threshold applies the rule at once.
func TestAReadingAtTheThresholdApplies(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
if b := mm.bound("laptop"); b.Licence != "b@example.org" || b.By != ByUsage {
t.Fatalf("laptop %+v", b)
}
}
@@ -0,0 +1 @@
{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription","refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":4,"changedAt":"2026-10-04T11:00:00.000Z"}
@@ -0,0 +1,129 @@
package main
// The module's acting tools (design 39 bus-and-switch §38, §39, §42; design 39 parts §4): registered by the manager
// bundle beside the seat's verbs, they start by hand what the manager does by itself. The tools that only read are the
// tools bundle's (cmd/tools).
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
. "claude-licence-manager/internal/licences"
)
// tool is one of the module's own tools.
func tool(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: name, Description: description, Input: input,
Run: func(a map[string]any) (any, error) {
m, err := manager()
if err != nil {
return nil, err
}
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)})
return run(ctx, m, a)
}}
}
func boolean(description string) map[string]any {
return map[string]any{"type": "boolean", "description": description}
}
func number(description string) map[string]any {
return map[string]any{"type": "number", "description": description}
}
func moduleTools() []stdio.Tool {
licence := str("a licence, as `licences` names it; absent for all")
dry := boolean("answer what the rule would do, and do nothing")
return []stdio.Tool{
tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.",
map[string]any{"licence": licence, "dry": dry},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
l, _ := a["licence"].(string)
d, _ := a["dry"].(bool)
return m.ReadUsageOf(ctx, strings.TrimSpace(l), d)
}),
tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.",
map[string]any{"licence": licence, "dry": dry},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
l, _ := a["licence"].(string)
d, _ := a["dry"].(bool)
acts, err := m.ApplyRule(ctx, strings.TrimSpace(l), true, d)
if acts == nil {
acts = []Switch{}
}
return acts, err
}),
tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.",
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) {
return m.Resync(ctx)
}),
}
}
// Resync says again what a consumer, the vault or a node may have missed (§42): binding.changed for every binding,
// holdings.asked, each condition held, and licence-put of each licence's access token. Answers what it said.
func (m *Manager) Resync(ctx context.Context) (map[string]any, error) {
bindings, err := m.Store.Bindings(ctx)
if err != nil {
return nil, err
}
said := map[string]any{}
consumers := []string{}
for _, b := range bindings {
m.changed(b, b.Licence)
consumers = append(consumers, b.Consumer)
}
said["binding.changed"] = consumers
if err := m.AskHoldings(CauseOf(ctx).By); err != nil {
return said, fmt.Errorf("holdings.asked could not be said: %w", err)
}
said["holdings.asked"] = true
if err := m.SayConditions(ctx, true); err != nil {
return said, err
}
keys, err := m.Conditions.Keys()
if err != nil {
return said, err
}
sort.Strings(keys)
said["conditions"] = keys
licences, err := m.Store.Licences(ctx)
if err != nil {
return said, err
}
put := []string{}
var errs []error
for _, l := range licences {
if l.Generation == 0 {
continue
}
key := KeyFor(l.Name)
// The access token is built from the grant, which the vault hands the manager; it hands a licence's access
// token only to a machine a binding names (§17), and the manager's machine may have none.
access, err := m.Grants.Get(ctx, key, PartGrant)
if err == nil && l.Kind == "subscription" {
var g FullGrant
if err = json.Unmarshal([]byte(access), &g); err == nil {
access = accessOf(g)
}
}
if err == nil {
err = m.Grants.Put(ctx, key, PartAccess, l.Generation, access)
}
access = ""
if err != nil {
errs = append(errs, fmt.Errorf("the access token of %s: %w", l.Name, err))
continue
}
put = append(put, l.Name)
}
said["licence-put"] = put
return said, errors.Join(errs...)
}
@@ -0,0 +1,166 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
. "claude-licence-manager/internal/licences"
)
// stdTool is one served verb or tool's run.
type stdTool func(map[string]any) (any, error)
// served is every verb and tool the binary serves, by name, run on mm's manager.
func served(t *testing.T, mm *miniMesh) map[string]stdTool {
t.Helper()
built = mm.m
t.Cleanup(func() { built = nil })
out := map[string]stdTool{}
for _, tool := range tools() {
out[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run)
}
return out
}
// answer is a tool's answer as JSON, decoded into a generic form.
func answer(t *testing.T, run stdTool, args map[string]any) any {
t.Helper()
got, err := run(args)
if err != nil {
t.Fatalf("%v answered %v", args, err)
}
raw, _ := json.Marshal(got)
var v any
_ = json.Unmarshal(raw, &v)
return v
}
// design 39 bus-and-switch §34/AC1, §47, B40: an answer per licence carries today's fields, its organisation, usage,
// room or why it is not known, whether it is the next target, when and from where it was adopted and its open
// conditions, and no token.
func TestLicencesAnswersEachFieldAndNoToken(t *testing.T) {
mm := fourOnA(t)
ctx := context.Background()
_ = mm.store.SaveLicence(ctx, Licence{Name: "k", Kind: "api-key", Generation: 1, AdoptedAt: t0.UnixMilli(),
AdoptedFrom: "a file"})
_, _ = saying(mm)
_ = mm.store.SaveLicence(ctx, sub("a", read(40, 95)))
_ = mm.m.SayConditions(ctx, false)
got := answer(t, served(t, mm)["licences"], nil).([]any)
by := map[string]map[string]any{}
for _, l := range got {
f := l.(map[string]any)
by[f["name"].(string)] = f
for _, k := range []string{"name", "kind", "account", "organisation", "accessExpiresAt", "refreshExpiresAt",
"failures", "bound", "usage", "room", "target", "adoptedAt", "adoptedFrom", "conditions"} {
if _, ok := f[k]; !ok {
t.Errorf("%s has no %s: %v", f["name"], k, f)
}
}
}
if by["b"]["target"] != true || by["c"]["target"] != false || by["a"]["room"] != 5.0 ||
by["k"]["room"] != "is an API key, whose room is never known" || by["k"]["adoptedFrom"] != "a file" ||
len(by["a"]["bound"].([]any)) != 4 || by["a"]["usage"].(map[string]any)["weeklyResetsAt"] != "2026-11-14T09:00:00Z" {
t.Fatalf("licences %v", by)
}
if cs := by["a"]["conditions"].([]any); len(cs) != 1 || cs[0].(map[string]any)["key"] != "licence.a.near-limit" {
t.Fatalf("a's conditions %v", cs)
}
raw, _ := json.Marshal(got)
for _, token := range []string{"rt-", "at-", "local-"} {
if strings.Contains(string(raw), token) {
t.Fatalf("licences holds %s: %s", token, raw)
}
}
}
// design 39 bus-and-switch §35/AC1: since and limit bound the answer, newest first; raw adds the vendor's answer.
func TestUsageIsBoundedBySinceAndLimit(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_ = mm.store.SaveLicence(ctx, sub("a", nil))
for i := range 30 {
_ = mm.store.RecordUsage(ctx, "a", t0.Add(time.Duration(i)*time.Minute).UnixMilli(), UsageReading{},
map[string]any{"i": float64(i)})
}
run := served(t, mm)["usage"]
rows := answer(t, run, map[string]any{"licence": "a"}).([]any)
first := rows[0].(map[string]any)
if len(rows) != 20 || first["at"] != float64(t0.Add(29*time.Minute).UnixMilli()) || first["raw"] != nil {
t.Fatalf("by default %d rows, the first %v", len(rows), first)
}
since := t0.Add(25 * time.Minute).UTC().Format(time.RFC3339)
rows = answer(t, run, map[string]any{"since": since, "limit": 3.0, "raw": true}).([]any)
if len(rows) != 3 || rows[0].(map[string]any)["raw"].(map[string]any)["i"] != 29.0 {
t.Fatalf("since and limit 3: %v", rows)
}
if rows = answer(t, run, map[string]any{"since": since, "limit": 5000.0}).([]any); len(rows) != 5 {
t.Fatalf("since: %d rows", len(rows))
}
if _, err := run(map[string]any{"since": "yesterday"}); err == nil {
t.Fatal("a since that is not a time is answered")
}
}
// design 39 bus-and-switch §38/AC1, §24, §32: reading usage now writes the reading and applies the rule, and with dry
// reads, answers the moves and moves nothing.
func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) {
ctx := context.Background()
for _, dry := range []bool{true, false} {
mm := newMesh(t)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
got := answer(t, served(t, mm)["licences_read_usage"], map[string]any{"dry": dry}).([]any)
r := got[0].(map[string]any)
moved := mm.bound("laptop").Licence == "b@example.org"
if len(got) != 1 || r["reading"].(map[string]any)["weeklyPct"] != 99.0 ||
len(r["switches"].([]any)) != 1 || moved == dry || len(mm.usage.under("")) != 1 {
t.Fatalf("dry %v: answered %v, moved %v", dry, got, moved)
}
}
}
// design 39 bus-and-switch §39/AC1, §32: switching now runs the rule with switch_auto off, and dry moves nothing; an
// acting tool waits for nothing to be carried over (design 39 parts §11).
func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) {
mm := fourOnA(t)
mm.m.Settings.SwitchAuto = false
run := served(t, mm)["licences_switch_now"]
if got := answer(t, run, map[string]any{"dry": true}).([]any); len(got) != 1 || mm.bound("ace").Licence != "a" {
t.Fatalf("dry %v", got)
}
if got := answer(t, run, map[string]any{"licence": "a"}).([]any); len(got) != 1 || mm.bound("ace").Licence != "b" {
t.Fatalf("switched %v, ace on %s", got, mm.bound("ace").Licence)
}
}
// design 39 bus-and-switch §42/AC1: resync says binding.changed once per binding, holdings.asked once, each condition
// once, and licence-put of each licence's access once.
func TestResyncSaysEachOnce(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, said := saying(mm)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.login("server", "rt-b", true, t0)})
mm.m.Settings.WarnAt = 100
_ = mm.m.SayConditions(ctx, false)
mm.events, *said = nil, nil
mm.grants.refuseAccess = true // §42: the access token is built from the grant, never read back from the vault
got := answer(t, served(t, mm)["licences_resync"], nil).(map[string]any)
count := map[string]int{}
for _, e := range mm.events {
name, _, _ := strings.Cut(e, " ")
if strings.HasPrefix(name, BindingChanged+".") {
name = BindingChanged
}
count[name]++
}
puts, _ := got["licence-put"].([]any)
if count["binding.changed"] != 2 || count[HoldingsAsked] != 1 || len(*said) != 1 || len(puts) != 1 || puts[0] != licence1 {
t.Fatalf("said %v, conditions %d, answered %v", mm.events, len(*said), got)
}
}
@@ -0,0 +1,79 @@
package main
// The vault holds every licence's grant and access token (design 39 bus-and-switch §15, §16, B4, B5): the manager hands
// what it gets to `licence-put` in two steps, with a key the vault made for the call, and reads a grant with `get`,
// sealed to its own public key, for one act.
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
"golang.org/x/crypto/nacl/box"
)
// The parts of a licence the vault holds (§15).
const (
PartGrant = "grant"
PartAccess = "access"
)
// Grants is where a licence's two secrets are kept.
type Grants interface {
// Put hands one part of a licence, by its key, to the vault at a generation; an older one is refused.
Put(ctx context.Context, key, part string, generation int64, value string) error
// Get reads one part of a licence from the vault.
Get(ctx context.Context, key, part string) (string, error)
}
// SecretName is the name of one part of a licence in the vault.
func SecretName(key, part string) string { return "licence." + key + "." + part }
// VaultGrants is the vault on the bus, asked as the seat mesh-vault.
type VaultGrants struct{ Keys KeyPair }
// Put asks licence-put's two steps.
func (v VaultGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
raw, err := stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"licence": key, "part": part, "generation": generation})
if err != nil {
return err
}
var opened struct {
Call string `json:"call"`
SealTo string `json:"seal_to"`
}
if err := json.Unmarshal(raw, &opened); err != nil {
return err
}
pub, err := base64.StdEncoding.DecodeString(opened.SealTo)
if err != nil || len(pub) != 32 || opened.Call == "" {
return fmt.Errorf("licence-put answered no call and key to seal to: %s", raw)
}
var to [32]byte
copy(to[:], pub)
sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader)
if err != nil {
return err
}
_, err = stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"call": opened.Call,
"sealed": base64.StdEncoding.EncodeToString(sealed)})
return err
}
// Get asks get, sealed to the manager's public key, and opens the answer.
func (v VaultGrants) Get(_ context.Context, key, part string) (string, error) {
raw, err := stdio.Ask("seat:mesh-vault.get", map[string]any{"name": SecretName(key, part), "public_key": v.Keys.PublicKey})
if err != nil {
return "", err
}
var a struct {
Sealed SealedBox `json:"sealed"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", err
}
return Open(a.Sealed, v.Keys.PrivateKey)
}
@@ -0,0 +1,45 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
. "claude-licence-manager/internal/licences"
)
// design 39 bus-and-switch §15, §16, §20: a rotation reads the grant from the vault and hands it back, the grant first
// and then the access, at the next generation; the licence keeps no token once the call returns.
func TestARotationPutsTwiceAndKeepsNoToken(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
if _, err := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); err != nil {
t.Fatal(err)
}
key := KeyFor(licence1)
mm.grants.puts = nil
if r, err := mm.m.Rotate(ctx, licence1, true); err != nil || r["refreshed"] != true {
t.Fatalf("%v %v", r, err)
}
want := []string{SecretName(key, PartGrant) + "@2", SecretName(key, PartAccess) + "@2"}
if strings.Join(mm.grants.puts, " ") != strings.Join(want, " ") {
t.Fatalf("the rotation put %v, want %v", mm.grants.puts, want)
}
if g, _ := mm.grants.Get(ctx, key, PartGrant); !strings.Contains(g, `"refreshToken":"rt-2"`) {
t.Fatalf("the vault holds the grant %s", g)
}
if a, _ := mm.grants.Get(ctx, key, PartAccess); !strings.Contains(a, `"accessToken":"at-2"`) || strings.Contains(a, "refreshToken\":\"rt") {
t.Fatalf("the vault holds the access %s", a)
}
l, _ := mm.store.Licence(ctx, licence1)
raw, _ := json.Marshal(l)
for _, token := range []string{"rt-", "at-"} {
if strings.Contains(string(raw), token) {
t.Fatalf("the licence keeps %s: %s", token, raw)
}
}
if l.Generation != 2 {
t.Fatalf("the licence's generation is %d, want 2", l.Generation)
}
}
@@ -14,6 +14,8 @@ import (
"os"
"strings"
"time"
. "claude-licence-manager/internal/licences"
)
// The public Claude Code client's id: not a secret, and a hard-won constant — a metadata URL in its place
@@ -140,6 +142,8 @@ func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
return NextGrant(g, r, time.Now().UnixMilli())
}
// Usage is the vendor's usage answer whole; a failed answer is an error naming its status and reason, the body, never
// only the status (design 39 bus-and-switch §24).
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
if err != nil {
@@ -148,25 +152,25 @@ func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]a
req.Header.Set("authorization", "Bearer "+accessToken)
resp, err := v.client.Do(req)
if err != nil {
return nil, err
return nil, fmt.Errorf("the usage endpoint did not answer: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode)
reason := string(body)
if len(reason) > 400 {
reason = reason[:400]
}
return nil, fmt.Errorf("the usage endpoint answered %d: %s", resp.StatusCode, reason)
}
var out map[string]any
return out, json.NewDecoder(resp.Body).Decode(&out)
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("the usage endpoint answered %d with what is not JSON", resp.StatusCode)
}
return out, nil
}
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows.
type UsageReading struct {
SessionPct *float64 `json:"sessionPct"`
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
WeeklyPct *float64 `json:"weeklyPct"`
SonnetPct *float64 `json:"sonnetPct"`
}
// FlattenUsage reads the windows the predecessor read.
// FlattenUsage reads the session, weekly and sonnet windows, each with its reset time (§24).
func FlattenUsage(u map[string]any) UsageReading {
window := func(k string) (*float64, string) {
w, ok := u[k].(map[string]any)
@@ -180,8 +184,9 @@ func FlattenUsage(u map[string]any) UsageReading {
}
return &pct, resets
}
s, resets := window("five_hour")
w, _ := window("seven_day")
so, _ := window("seven_day_sonnet")
return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so}
s, sResets := window("five_hour")
w, wResets := window("seven_day")
so, soResets := window("seven_day_sonnet")
return UsageReading{SessionPct: s, SessionResetsAt: sResets, WeeklyPct: w, WeeklyResetsAt: wResets, SonnetPct: so,
SonnetResetsAt: soResets}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
// claude-licence-manager-tools (design 39 parts §3): the licence manager's tools bundle, loaded by the module's runtime
// of its own beside the manager bundle. It answers the module's four reading tools and writes nothing: it reads the
// module's kept data with the runtime's bus-state calls and its two logs with the runtime's log calls, each answered
// as the module whose bundle asks, and asks mesh-config where each setting was set. The runtime serves the tools of
// every bundle of a module that loaded, so these answer while the manager bundle fails to load or stops.
//
// stdout is the MCP channel; everything this bundle says, it says on stderr.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
"claude-licence-manager/internal/licences"
)
func main() {
if err := stdio.Serve("", fromTheRuntime().Tools()); err != nil {
fmt.Fprintf(os.Stderr, "[claude-licence-manager-tools] %v\n", err)
os.Exit(1)
}
}
// fromTheRuntime is the reader of the module's kept data through its runtime, and of its settings file by the word
// the manager bundle gives the module (MESH_LICENCE_SETTINGS; ADR 0192: the runtime hands a module's words to all
// its bundles).
func fromTheRuntime() *Reader {
node, _ := os.Hostname()
if n := os.Getenv("MESH_NODE"); n != "" {
node = n
}
r := &Reader{States: map[string]licences.State{}, Logs: map[string]licences.Log{},
SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"),
ConfigLevels: func(context.Context) (map[string]string, error) { return configLevels(node) }}
for _, name := range licences.States {
r.States[name] = stdio.State(name)
}
for _, name := range licences.Logs {
r.Logs[name] = stdio.Log(name)
}
return r
}
// configLevels asks mesh-config where each of the module's own settings was set on its node (bus-and-switch §40,
// B18): the source of each resolved value, by its key.
func configLevels(node string) (map[string]string, error) {
raw, err := stdio.Ask("seat:mesh-config.config", map[string]any{"action": "list", "module": licences.Publisher,
"node": node})
if err != nil {
return nil, err
}
var answer struct {
Resolved []struct {
Key string `json:"key"`
Source string `json:"source"`
} `json:"resolved"`
}
if err := json.Unmarshal(raw, &answer); err != nil {
return nil, fmt.Errorf("mesh-config's answer: %w", err)
}
levels := map[string]string{}
for _, r := range answer.Resolved {
levels[r.Key] = r.Source
}
return levels, nil
}
@@ -0,0 +1,276 @@
package main
// The module's four reading tools (design 39 parts §3; bus-and-switch §37, §40, §41, §43): they read everything the
// manager keeps and answer it, and write nothing. Every act is the manager bundle's (parts §4).
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"claude-licence-manager/internal/licences"
)
// Reader reads the module's kept data and its settings, as the module whose bundle asks.
type Reader struct {
// States and Logs are the module's kept states and logs, by their names in the manifest.
States map[string]licences.State
Logs map[string]licences.Log
// SettingsFile is the settings file the node-engine writes, read again at every call (bus-and-switch §33).
SettingsFile string
// ConfigLevels is where mesh-config says each setting was set on the manager's node (mesh, node or default), by
// setting (§40, B18).
ConfigLevels func(ctx context.Context) (map[string]string, error)
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func number(description string) map[string]any {
return map[string]any{"type": "number", "description": description}
}
// sinceOf is the argument since, RFC 3339; absent is the zero time.
func sinceOf(a map[string]any) (time.Time, error) {
v, _ := a["since"].(string)
if strings.TrimSpace(v) == "" {
return time.Time{}, nil
}
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(v))
if err != nil {
return time.Time{}, fmt.Errorf("since is not a time in RFC 3339: %w", err)
}
return t, nil
}
// limitOf is the argument limit: def when absent or not positive, at most most.
func limitOf(a map[string]any, def, most int) int {
limit := def
if v, ok := a["limit"].(float64); ok && v > 0 {
limit = int(v)
}
return min(limit, most)
}
// Tools are the four reading tools, registered under the module (parts §3).
func (r *Reader) Tools() []stdio.Tool {
licence := str("a licence, as `licences` names it; absent for all")
return []stdio.Tool{
{Name: "licences_switches", Description: "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.",
Input: map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"),
"since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")},
Run: func(a map[string]any) (any, error) {
l, _ := a["licence"].(string)
c, _ := a["consumer"].(string)
since, err := sinceOf(a)
if err != nil {
return nil, err
}
return r.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000))
}},
{Name: "licences_settings", Description: "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.",
Run: func(map[string]any) (any, error) { return r.SettingsInForce(context.Background()) }},
{Name: "licences_kept", Description: "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.",
Input: map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"),
"key": str("a key of a state, or a subject of a log")},
Run: func(a map[string]any) (any, error) {
e, _ := a["entry"].(string)
k, _ := a["key"].(string)
return r.Kept(strings.TrimSpace(e), strings.TrimSpace(k))
}},
{Name: "licences_conditions", Description: "The conditions the manager said and holds: each key of its state conditions with its body and since.",
Run: func(map[string]any) (any, error) {
return licences.KeptConditions(r.States[licences.EntryConditions])
}},
}
}
// carriedForm gives an entry carried over from the old table `audit` (§12) the fields of §37: the old manager wrote a
// switch as `consumer` (one), `licence` (the one moved to), `from` and `by`, and no `consumers` or `to`.
func carriedForm(e map[string]any) {
if _, ok := e["to"]; !ok {
if l, ok := e["licence"]; ok {
e["to"] = l
}
}
if _, ok := e["consumers"]; !ok {
if c, ok := e["consumer"]; ok {
e["consumers"] = []any{c}
}
}
}
// Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before
// since, at most limit (§37).
func (r *Reader) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) {
audit, ok := r.Logs[licences.EntryAudit]
if !ok {
return nil, fmt.Errorf("the module keeps no log %s", licences.EntryAudit)
}
info, err := audit.Info()
if err != nil || info.Last == 0 {
return []map[string]any{}, err
}
var all []stdio.LogEntry
for next := uint64(1); next != 0; {
page, err := audit.Read(next, "switched", 0)
if err != nil {
return nil, err
}
all = append(all, page.Entries...)
next = page.Next
}
out := []map[string]any{}
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
var e map[string]any
if json.Unmarshal(all[i].Value, &e) != nil {
continue
}
carriedForm(e)
at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"]))
if at.Before(since) {
continue
}
if licence != "" && e["from"] != licence && e["to"] != licence {
continue
}
consumers, _ := e["consumers"].([]any)
if consumer != "" && !slices.Contains(consumers, any(consumer)) {
continue
}
s := map[string]any{"at": e["at"], "from": e["from"], "to": e["to"], "consumers": e["consumers"],
"by": e["by"], "why": e["why"]}
if _, ok := e["window"]; ok {
s["reading"] = map[string]any{"window": e["window"], "pct": e["pct"], "resetsAt": e["resetsAt"],
"room": e["room"]}
}
out = append(out, s)
}
return out, nil
}
// SettingAnswer is one setting as licences_settings answers it (§40).
type SettingAnswer struct {
Setting string `json:"setting"`
Value any `json:"value"`
Default any `json:"default"`
Level string `json:"level"`
Invalid string `json:"invalid,omitempty"`
}
// SettingsInForce is each setting in force, read from the settings file, its default, where mesh-config says it was
// set, and why it is invalid when it is (§40, B18).
func (r *Reader) SettingsInForce(ctx context.Context) ([]SettingAnswer, error) {
levels := map[string]string{}
if r.ConfigLevels != nil {
got, err := r.ConfigLevels(ctx)
if err != nil {
return nil, fmt.Errorf("mesh-config did not say where each setting was set: %w", err)
}
levels = got
}
s := licences.SettingsFrom(r.SettingsFile)
reasons := s.Reasons()
values, defaults := s.Values(), licences.Defaults.Values()
var out []SettingAnswer
for k, v := range values {
a := SettingAnswer{Setting: k, Value: v, Default: defaults[k], Level: levels[k], Invalid: reasons[k]}
if a.Level == "" {
a.Level = "default"
}
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return out[i].Setting < out[j].Setting })
return out, nil
}
// Kept answers one entry the module keeps, or every one (§41): a state's keys and their count, a log's subjects with
// their last entry and its fill against its cap; with key, one key's value or one subject's last entry.
func (r *Reader) Kept(entry, key string) (map[string]any, error) {
out := map[string]any{}
names := []string{}
for n := range r.States {
names = append(names, n)
}
for n := range r.Logs {
names = append(names, n)
}
sort.Strings(names)
if entry != "" && !slices.Contains(names, entry) {
return nil, fmt.Errorf("the manager keeps no entry %q: it keeps %s", entry, strings.Join(names, ", "))
}
for _, n := range names {
if entry != "" && n != entry {
continue
}
if st, ok := r.States[n]; ok {
a, err := stateAnswer(st, key)
if err != nil {
return nil, fmt.Errorf("%s: %w", n, err)
}
out[n] = a
continue
}
a, err := logAnswer(r.Logs[n], key)
if err != nil {
return nil, fmt.Errorf("%s: %w", n, err)
}
out[n] = a
}
return out, nil
}
func stateAnswer(st licences.State, key string) (map[string]any, error) {
keys, err := st.Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
a := map[string]any{"kind": "state", "keys": keys, "fill": map[string]any{"keys": len(keys), "cap": "none declared"}}
if key != "" {
e, err := st.Get(key)
if err != nil {
return nil, err
}
if e == nil {
a["value"] = nil
} else {
a["value"] = e.Value
}
}
return a, nil
}
func logAnswer(l licences.Log, key string) (map[string]any, error) {
info, err := l.Info()
if err != nil {
return nil, err
}
a := map[string]any{"kind": "log", "fill": map[string]any{"entries": info.Entries, "bytes": info.Bytes,
"maxBytes": info.MaxBytes}}
if key != "" {
e, err := l.Last(key)
if err != nil {
return nil, err
}
a["last"] = e
return a, nil
}
newest, err := l.Newest("")
if err != nil {
return nil, err
}
subjects := map[string]any{}
for _, e := range newest {
subjects[e.Key] = e
}
a["subjects"] = subjects
return a, nil
}
@@ -0,0 +1,360 @@
package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"testing"
"time"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"claude-licence-manager/internal/licences"
)
// runtime is a fixture of the runtime's bus-state and log calls (design 39 parts §3): it answers every read from what
// a test seeds, and fails the test on any write or append the tools bundle makes.
type runtime struct {
t *testing.T
states map[string]map[string]json.RawMessage
logs map[string][]stdio.LogEntry
}
func newRuntime(t *testing.T) *runtime {
rt := &runtime{t: t, states: map[string]map[string]json.RawMessage{}, logs: map[string][]stdio.LogEntry{}}
for _, s := range licences.States {
rt.states[s] = map[string]json.RawMessage{}
}
for _, l := range licences.Logs {
rt.logs[l] = nil
}
return rt
}
// seedState and seedLog keep what the manager wrote before the tools were asked.
func (rt *runtime) seedState(state, key string, v any) {
raw, _ := json.Marshal(v)
rt.states[state][key] = raw
}
func (rt *runtime) seedLog(log, key string, v any) {
raw, _ := json.Marshal(v)
rt.logs[log] = append(rt.logs[log], stdio.LogEntry{Sequence: uint64(len(rt.logs[log]) + 1), Key: key, Value: raw})
}
func (rt *runtime) reader() *Reader {
r := &Reader{States: map[string]licences.State{}, Logs: map[string]licences.Log{}}
for s := range rt.states {
r.States[s] = keptState{rt, s}
}
for l := range rt.logs {
r.Logs[l] = keptLog{rt, l}
}
return r
}
type keptState struct {
rt *runtime
name string
}
func (s keptState) wrote(how, key string) error {
s.rt.t.Errorf("the tools bundle %s %s.%s: it writes nothing", how, s.name, key)
return errors.New("refused: the tools bundle writes nothing")
}
func (s keptState) Get(key string) (*stdio.StateEntry, error) {
v, ok := s.rt.states[s.name][key]
if !ok {
return nil, nil
}
return &stdio.StateEntry{Key: key, Value: v, Revision: 1}, nil
}
func (s keptState) Put(key string, _ any) (uint64, error) { return 0, s.wrote("put", key) }
func (s keptState) PutTTL(key string, _ any, _ time.Duration) (uint64, error) {
return 0, s.wrote("put", key)
}
func (s keptState) Create(key string, _ any) (uint64, error) { return 0, s.wrote("created", key) }
func (s keptState) CreateTTL(key string, _ any, _ time.Duration) (uint64, error) {
return 0, s.wrote("created", key)
}
func (s keptState) Delete(key string) error { return s.wrote("deleted", key) }
func (s keptState) Keys() ([]string, error) {
keys := []string{}
for k := range s.rt.states[s.name] {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}
func (s keptState) History(key string) ([]stdio.StateRevision, error) { return nil, nil }
type keptLog struct {
rt *runtime
name string
}
func (l keptLog) Append(key string, _ any) (uint64, error) {
l.rt.t.Errorf("the tools bundle appended to %s.%s: it writes nothing", l.name, key)
return 0, errors.New("refused: the tools bundle writes nothing")
}
func (l keptLog) Read(from uint64, keys string, _ int) (*stdio.LogPage, error) {
page := &stdio.LogPage{Last: uint64(len(l.rt.logs[l.name]))}
for _, e := range l.rt.logs[l.name] {
if e.Sequence >= from && (keys == "" || e.Key == keys) {
page.Entries = append(page.Entries, e)
}
}
return page, nil
}
func (l keptLog) Last(key string) (*stdio.LogEntry, error) {
var last *stdio.LogEntry
for _, e := range l.rt.logs[l.name] {
if e.Key == key {
last = &e
}
}
return last, nil
}
func (l keptLog) Info() (*stdio.LogInfo, error) {
n := uint64(len(l.rt.logs[l.name]))
return &stdio.LogInfo{Entries: n, Last: n, First: min(n, 1), MaxBytes: 64 << 20}, nil
}
func (l keptLog) Newest(string) ([]stdio.LogEntry, error) {
by := map[string]stdio.LogEntry{}
for _, e := range l.rt.logs[l.name] {
by[e.Key] = e
}
out := []stdio.LogEntry{}
for _, e := range by {
out = append(out, e)
}
return out, nil
}
// run is one tool's answer as JSON, decoded into a generic form.
func run(t *testing.T, r *Reader, name string, args map[string]any) any {
t.Helper()
for _, tool := range r.Tools() {
if tool.Name != name {
continue
}
got, err := tool.Run(args)
if err != nil {
t.Fatalf("%s %v answered %v", name, args, err)
}
raw, _ := json.Marshal(got)
var v any
_ = json.Unmarshal(raw, &v)
return v
}
t.Fatalf("the tools bundle registers no %s", name)
return nil
}
// design 39 parts §3, §4: the tools bundle registers exactly the four reading tools; its code emits nothing,
// subscribes to no event, asks no heartbeat, calls neither the vendor nor the vault, and asks nothing but mesh-config.
func TestTheToolsBundleRegistersTheFourReadingToolsAndActsOnNothing(t *testing.T) {
var names []string
for _, tool := range newRuntime(t).reader().Tools() {
names = append(names, tool.Name)
}
sort.Strings(names)
if want := []string{"licences_conditions", "licences_kept", "licences_settings", "licences_switches"}; !slices.Equal(names, want) {
t.Fatalf("the tools bundle registers %v, not %v", names, want)
}
files, _ := filepath.Glob("*.go")
acts := regexp.MustCompile(`stdio\.(Emit|Subscribe|SeatPublish|Publish|Heartbeat|Watch)\b|\.(Put|PutTTL|Create|CreateTTL|Delete|Append)\(|os\.(WriteFile|Create|OpenFile)|http\.`)
asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`)
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, m := range acts.FindAllString(string(src), -1) {
t.Errorf("%s calls %s: the tools bundle acts on nothing", f, m)
}
for _, m := range asked.FindAllStringSubmatch(string(src), -1) {
if m[1] != "seat:mesh-config.config" {
t.Errorf("%s asks %s: the tools bundle asks only mesh-config", f, m[1])
}
}
}
}
// design 39 bus-and-switch §37/AC1, design 39 parts §3: a switch by usage and one by a person are answered, newest
// first, with their causes, a switch by usage with the reading that moved them; licence and consumer narrow the
// answer; nothing is written.
func TestSwitchesAnswersBothCauses(t *testing.T) {
rt := newRuntime(t)
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-12T10:00:00Z", "from": "a", "to": "b",
"consumers": []string{"ace", "g14"}, "by": "usage", "why": "a at 99 %", "window": "weekly", "pct": 99,
"resetsAt": "2026-11-14T09:00:00Z", "room": 60})
rt.seedLog(licences.EntryAudit, "bound", map[string]any{"at": "2026-11-12T10:00:30Z", "consumer": "ace"})
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-12T10:01:00Z", "from": "b", "to": "c",
"consumers": []string{"ace"}, "by": "a person", "why": "the switch verb"})
r := rt.reader()
got := run(t, r, "licences_switches", nil).([]any)
if len(got) != 2 {
t.Fatalf("switches %v", got)
}
person, usage := got[0].(map[string]any), got[1].(map[string]any)
if person["by"] != "a person" || person["why"] != "the switch verb" || person["to"] != "c" || person["reading"] != nil ||
usage["by"] != "usage" || usage["reading"].(map[string]any)["window"] != "weekly" || usage["from"] != "a" {
t.Fatalf("switches %v", got)
}
if got := run(t, r, "licences_switches", map[string]any{"licence": "c"}).([]any); len(got) != 1 {
t.Fatalf("of c %v", got)
}
if got := run(t, r, "licences_switches", map[string]any{"consumer": "g14"}).([]any); len(got) != 1 ||
got[0].(map[string]any)["by"] != "usage" {
t.Fatalf("of g14 %v", got)
}
if got := run(t, r, "licences_switches", map[string]any{"since": "2026-11-12T10:00:30Z"}).([]any); len(got) != 1 {
t.Fatalf("since %v", got)
}
}
// design 39 bus-and-switch §37, §12: a switch carried over from the old table keeps the old field names (consumer,
// licence) and is answered with `to` and `consumers`, and found by them.
func TestSwitchesAnswersCarriedEntries(t *testing.T) {
rt := newRuntime(t)
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-11T21:10:33Z", "consumer": "n1",
"licence": "d@example.org", "from": "c@example.org", "by": "a@example.org"})
r := rt.reader()
for _, args := range []map[string]any{nil, {"consumer": "n1"}, {"licence": "d@example.org"}} {
got := run(t, r, "licences_switches", args).([]any)
if len(got) != 1 {
t.Fatalf("%v: %v", args, got)
}
e := got[0].(map[string]any)
if e["to"] != "d@example.org" || e["from"] != "c@example.org" || e["by"] != "a@example.org" ||
len(e["consumers"].([]any)) != 1 || e["consumers"].([]any)[0] != "n1" {
t.Fatalf("%v: %v", args, e)
}
}
}
// design 39 bus-and-switch §40/AC1, B18, design 39 parts §3: a value set at node level is answered with its level, the
// rest with the default, each with its value and default, and an invalid one saying why, read from the settings file;
// mesh-config not answering is an error, never the default.
func TestSettingsAnswersTheLevel(t *testing.T) {
r := newRuntime(t).reader()
r.SettingsFile = filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(r.SettingsFile, []byte(`{"switch_at_pct": 80, "warn_at_pct": 85, "usage_minutes": 900}`), 0o600); err != nil {
t.Fatal(err)
}
r.ConfigLevels = func(context.Context) (map[string]string, error) {
return map[string]string{"switch_at_pct": "node", "warn_at_pct": "mesh", "cadence_minutes": "default"}, nil
}
by := map[string]map[string]any{}
for _, s := range run(t, r, "licences_settings", nil).([]any) {
f := s.(map[string]any)
by[f["setting"].(string)] = f
}
str := func(v any) string { s, _ := v.(string); return s }
if len(by) != len(licences.Defaults.Values()) || by["switch_at_pct"]["level"] != "node" ||
by["switch_at_pct"]["value"] != 80.0 || by["switch_at_pct"]["default"] != 99.0 ||
by["usage_minutes"]["level"] != "default" || by["usage_minutes"]["value"] != 5.0 ||
!strings.Contains(str(by["usage_minutes"]["invalid"]), "outside its bound") ||
!strings.Contains(str(by["room_below_pct"]["invalid"]), "above switch_at_pct 80") ||
!strings.Contains(str(by["warn_at_pct"]["invalid"]), "85") || by["switch_auto"]["invalid"] != nil {
t.Fatalf("settings %v", by)
}
r.ConfigLevels = func(context.Context) (map[string]string, error) { return nil, errors.New("no responders") }
for _, tool := range r.Tools() {
if tool.Name == "licences_settings" {
if _, err := tool.Run(nil); err == nil {
t.Fatal("mesh-config not answering is answered as the default")
}
}
}
}
// design 39 bus-and-switch §41/AC1, design 39 parts §3, §10: every bus-state entry the manifest asks for is read by
// licences_kept, with its keys or subjects, one key's value and its fill, the carry-over's record among them; an
// entry not kept is refused; nothing is written.
func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Requires []struct {
Name string `json:"name"`
Template string `json:"template"`
} `json:"requires"`
Logs []struct {
Name string `json:"name"`
} `json:"logs"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
rt := newRuntime(t)
rt.seedState(licences.EntryLicences, "a_at_example.org", licences.Licence{Name: "a@example.org", Kind: "subscription"})
rt.seedState(licences.EntryCarried, "postgres", map[string]any{"at": "2026-11-12T09:44:59Z"})
rt.seedLog(licences.EntryUsage, "a_at_example.org", licences.UsageEntry{At: "2026-11-12T10:00:00Z"})
r := rt.reader()
all := run(t, r, "licences_kept", nil).(map[string]any)
kinds := map[string]string{}
for _, e := range m.Requires {
kinds[e.Name] = e.Template
}
for _, l := range m.Logs {
kinds[l.Name] = "log"
}
for name, kind := range kinds {
got, ok := all[name].(map[string]any)
if !ok || got["fill"] == nil || got["kind"] != kind {
t.Errorf("licences_kept does not read %s as a %s: %v", name, kind, all[name])
}
}
if len(kinds) != 8 || len(all) != len(kinds) {
t.Fatalf("the manifest asks for %d entries, licences_kept reads %d", len(kinds), len(all))
}
one := run(t, r, "licences_kept", map[string]any{"entry": "carried", "key": "postgres"}).(map[string]any)
if v, _ := one["carried"].(map[string]any)["value"].(map[string]any); v["at"] != "2026-11-12T09:44:59Z" {
t.Fatalf("the carry-over's record %v", one)
}
if subjects := all["usage"].(map[string]any)["subjects"].(map[string]any); subjects["a_at_example.org"] == nil {
t.Fatalf("usage subjects %v", subjects)
}
if _, err := r.Kept("tables", ""); err == nil {
t.Fatal("an entry not kept is answered")
}
}
// design 39 bus-and-switch §43/AC1, design 39 parts §3: each key of conditions is answered with its body and since.
func TestConditionsAnswersEachKey(t *testing.T) {
rt := newRuntime(t)
for _, k := range []string{"settings.room_below_pct.invalid", "licence.a.near-limit"} {
rt.seedState(licences.EntryConditions, k, licences.KeptCondition{Body: oc.Condition{Key: k, Headline: "h"},
Since: "2026-11-12T10:00:00Z"})
}
var keys []string
for _, c := range run(t, rt.reader(), "licences_conditions", nil).([]any) {
f := c.(map[string]any)
if f["since"] == "" || f["body"].(map[string]any)["headline"] == "" {
t.Fatalf("condition %v", f)
}
keys = append(keys, f["body"].(map[string]any)["key"].(string))
}
if strings.Join(keys, ",") != "licence.a.near-limit,settings.room_below_pct.invalid" {
t.Fatalf("keys %v", keys)
}
}
@@ -1 +0,0 @@
postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}
@@ -3,5 +3,11 @@
"floor_minutes": 60,
"failures_to_notify": 3,
"cooldown_hours": 24,
"refresh_warn_days": 3
"refresh_warn_days": 3,
"switch_auto": true,
"switch_at_pct": 99,
"room_below_pct": 90,
"warn_at_pct": 90,
"usage_minutes": 5,
"usage_stale_minutes": 15
}
+10 -8
View File
@@ -3,20 +3,22 @@ module claude-licence-manager
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
github.com/jackc/pgx/v5 v5.11.0
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.16.0 // indirect
)
+23 -23
View File
@@ -1,38 +1,38 @@
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

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