Compare commits

...
39 Commits
Author SHA1 Message Date
mesh-tools 4a4fac090e Merge pull request 'The enrolment seat and node-login rotation contract: a joining machine's first tunnel, add-node and remove-node, two logins per node (issue 774)' (#157) from feat/l3-enrol into main 2026-11-02 07:56:24 +00:00
jschoubben dff0ce2abb Say why LeaseHandedAway and LeaseCut are two fields: the cut precedes the holder's write and outlasts it at a hand-back (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@4a4fac090e9e
mesh/delivery-group group feat/l3-enrol merged: every member is merged
mesh/check failed (2 tests): integration: mesh-mail-reader/cmd/mesh-mail-reader.TestAcceptanceOneMatteringMailSendsExactlyOneTellNamingIt: timed out w…
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:46 +01:00
jschoubben c816666f83 Name the enrolment an address is reserved for, from its ask, and the issued event's id in one place
ADR 0589 §6: an address key gains the issue's ask and time, written when the ask is raised. The key is written at the
reservation, before the ask, so both are empty then and both set from the ask; one without the other is refused, as
is no node. EncodeEnrolmentAddress takes node, ask and issued. EnrolmentIssuedID builds the enrolment-issued event's
id, enrolment-issued.<node>.<issue time in Unix nanoseconds>, as the holder names it today. Tested as rows of
TestNodeRecord; no new test function (1438 unit tests).

(issue 774, review round 1b)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben 2d717777b1 Carry the machine's tunnel key on its enrolment, so the holder places it on the operator's yes
The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue. Enrolment gains overlay-key, required at
every stage and refused unless it is 32 bytes in base64. Tested as rows of TestNodeRecord; no new test function (1438
unit tests).

(issue 774, review round 1b)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben f913918a60 Take the tunnel key at issue and place a joining node on the operator's yes, so the hub peers it before redeem
The operator's decision of 2026-11-02 01:05: issue requires overlay_key, the machine's tunnel public key; the vault's
enrolment-token tunnel declares all five fields a token needs (hub_key, hub_endpoint, address, key, range). An
add-node is proposed at the yes with the tunnel key alone; the identity keys come whole at redeem or not at all. A new
remove-node, mesh-enrolment's alone on the same warrant and the proposal's one change, takes a joining node running
nothing off placement when its enrolment ends unconfirmed (RemovedNodes names the key the holder deletes; LayChanges
lays nothing for it and refuses a node that runs modules or holds seats). NodeLogin gains Acts, the hand acts that
warranted its rotations. Tested as rows of TestPlacement, TestNodeRecord and the two seat tests; no new test function
(1438 unit tests).

(issue 774, review round 1b)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben 38a0bbca44 Let enrolment-token take the first tunnel, so the token the vault makes carries it as today
Lead's decision on milestone point 7 (a progressive insight of ADR 0587 §3): mesh-enrolment builds the joining
node's first tunnel at issue and passes it to the vault's enrolment-token as `tunnel`, an object of hub_key,
hub_endpoint and address, each required; the vault puts it in the token (delivery.TokenTunnel). Tested as a row of
TestTheVaultsSeatPromisesItsIntakeVerbs; no new test function (1438 unit tests).

(issue 774, review round 1)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben 79ad8b539c Name the expired enrolment's issued event by its id, and carry a node's login fall back in its health report
Blocker 6: enrolment-expired carries the enrolment-issued event's message id (IssuedID), since a bundle's publish
answers no stream sequence and ADR 0587 §2 asks only that the event be named; it gains a codec refusing no event named.
Blocker 7: NodeHealth gains Fallback (node, the login fallen back from, why, at), from which the watchman raises
node-account-fallback (ADR 0583 §6); a fall back naming another node is refused. A token missing its signer now says
the vault's signing key, not the retired word. Tested as rows of TestNodeRecord and the control records test; no new
test function (1438 unit tests).

(issue 774, review round 1)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben 90a5fc5797 Let placement take an add-node with its enrolment's warrant, so one Approve lets a machine join
ADR 0587 §7: at redeem mesh-enrolment proposes one change, the node with its decided fields (the approved address,
never the hub) and the public halves of its keys, carrying the issue's warrant; mesh-placement checks the warrant
names this node and address and that the caller is mesh-enrolment, and writes the key with no ask of its own
(changing ADR 0567 §2 and §5 for this one change by this one caller). The proposal codec refuses an add-node beside
other changes, without a warrant, or whose warrant names another node or address, and a warrant on any other
proposal; AddNodeRefusal adds the caller's check for the assigner; LayChanges adds the node and refuses one placed
already. Tested as rows of TestPlacement's proposal subtest, no new test function.

(issue 774, 774/S42)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 362dddae40 Compose mesh-enrolment's grants: its bucket written by it alone, the vault's enrolment verbs called by it alone
ADR 0587 §1-§3 (changing ADR 0561 §5) make mesh-enrolment the only caller of the vault's enrolment-token and
node-account and a caller of sign, and §2 its bucket enrolments read by it and by the grants part as a named reader.
The holder calls the vault through its manifest's invokes, in its own name, as any caller does; while the vault serves
either enrolment verb every other principal is denied both, however wide its grant (a runtime's or an agent's `*`),
as ControllerOnly denies the desk's prompt. The writers table gains the open enrolments (mesh-enrolment) and the
node-logins (the vault), each one writer; NamedReaders names the grants part for enrolments. Golden lists unchanged.
Tested as a subtest of TestTheCopiedComposerComposesAsTheControllers, no new test function.

(issue 774, 774/S41)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 9169830210 Grant an enrolling node's user the seat's redeem alone, so from the lease write no token reaches the controller
ADR 0587 §4: the grants part composes enrol.<node> granted only to call mesh-enrolment.redeem and to hear its own
inbox; §6: from the lease write the controller's enrolment subject is not served. The enrolment principal now calls
redeem in its own name while the seat's holder serves it, and keeps mesh.control.enrol only until Records says the
lease is handed away (a field the grants part sets and the controller's composer never does). A user left able to
enrol nowhere is refused by name rather than composed empty. Golden lists unchanged. Tested as a subtest of
TestTheCopiedComposerComposesAsTheControllers, no new test function.

(issue 774, 774/S40)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 8c2ce57eee Compose a node's two logins from the vault's node-logins, so a rotation can switch with both loaded
ADR 0583 §1: a node's account rotates between node.<n> and node.<n>--b, each with the node's permissions and its
inbox _INBOX.node.<n>. Records gains NodeLogins (each node's key of node-logins; the context file's NodeRotating,
renamed because a node that rotated and rotates no more logs in as its second login alone), and Users composes one
node user per login: the one in use and, while it rotates, the other. Each login calls in its own name, which is how
the vault tells the login that confirms (§3). While the vault serves rotate-node, each node reads its own node's key
of node-logins alone and may call node-account-confirm in its own name (§1 insight); the grants part is the state's
one named reader. With no rotation every list is the controller's golden one, unchanged. Tested as a subtest of
TestTheCopiedComposerComposesAsTheControllers, no new test function.

(issue 774, 774/S39)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 69270a30d5 Add the vault's rotation verbs and the state node-logins, so a node's account rotates over two logins on one contract
ADR 0583 §1 (insight 2026-11-01) keeps which login a node uses in the vault's state node-logins, one key per node:
the login in use, the one rotating and the stage (begun, ready, confirmed), with the list revision that held both.
§2, §3 and §5 give the vault's seat rotate-node (with the recorded hand act's id), node-account-confirm (the caller
is the proof) and abandon, optional until the vault serves them; the events rotation-begun, node-account-ready,
rotation-done and the engine's finding node-account-fallback are named beside them. The codec refuses a stage without
a rotation and a rotation to anything but the node's other login; an empty rotating reads as none. Tested as rows of
TestNodeRecord and TestTheVaultsSeatPromisesItsIntakeVerbs, no new test function.

(issue 774, 774/S38)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben d7a00e1f26 Add the vault's enrolment verbs to its seat, so mesh-enrolment asks the vault to mint and sign and holds nothing
ADR 0587 §3 and §5 (changing ADR 0561 §5) have the vault make the token and its hash (enrolment-token), answer it
sealed to the operator key for tokens to relay (sealed, §8), drop both at every end (drop), and mint, answer again
and retire a joining node's account (node-account), with mesh-enrolment their only caller; sign names mesh-enrolment
for a joining node's certificate (with the serving key it presented, since the node has no record yet) and for a
redeem answer. Both verbs optional until the vault that runs serves them. Their answers' shapes are in go/delivery.
Tested as rows of TestTheVaultsSeatPromisesItsIntakeVerbs, no new test function.

(issue 774, 774/S37)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 2ee75954ad Move the joining token's shape into mesh-sdk, so the vault that makes it, mesh-cli and the engine read one type
From the lease write the vault makes the token (ADR 0587 §3), mesh-cli opens and prints it, and the node-engine
parses it: three repositories, so the type leaves mesh-host's internal/identity (which keeps an alias) for
go/delivery, field names unchanged from the controller's token issue, the tunnel kept (the lead's settlement 7).
Encode is added, refusing a token with a part missing. The test reads a token the controller's own code made and
encodes it back to the same line. Paid by merging TestGateBuckets into TestKeysOfPlainNamesAreUnchanged.

(issue 774, 774/S36)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 959f57294a Add the contract of the enrolment state and of redeem, so the holder, the grants part and the engine read one shape
ADR 0587 §2 gives the seat mesh-enrolment the bucket enrolments with keys node.<node> (an Enrolment: node, address,
why, caller, ask, stage, issued, expires, keys digest) and address.<address> (the node's name alone), no secret in
either. The token's life rules (an hour by default, at most a day, §4), the event enrolment-expired's content (§2),
and redeem's call, proof bytes, keys digest and signed answer (§6) live here too, because the holder in
mesh-delivery and the engine in mesh-host both read them. The proof binds the node and its keys, not the token's
secret, which the holder never holds (§1). Tested as rows of TestNodeRecord, no new test function.

(issue 774, 774/S35)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 59b4c7910b Add the seat mesh-enrolment: six verbs and five events, so the module that joins a machine has a role the judge knows
ADR 0587 §1 makes joining a machine a seat held once by the module mesh-enrolment, the vault's only caller for its
enrolment verbs. The seat is in the mesh's own set, its verbs not optional (the seat is new with its holder), and
its events the five that carry no secret. The test is a block in TestHoldingAMeshSeatRequiresServingItsVerbs, no new
test function.

(issue 774, 774/S34)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
mesh-tools 5758e9fb57 Merge pull request 'The lease write's contract: the seat mesh-grants, the lease cut, processes follow their membership from state alone (issue 774)' (#156) from feat/l3-removal into main 2026-11-02 00:35:20 +00:00
jschoubben c21d8fd969 Fold the seat mesh-grants' test into the seat-verb test, within the test budget
mesh/delivery merged: source.main moved to novox/mesh-sdk@5758e9fb57ab
mesh/delivery-group group feat/l3-removal merged: every member is merged
mesh/check failed: mesh-build-go: the check of novox/mesh-catalog#590: the ask mvyhumdm860c7f's step on shanks did not end within 30m0s of its start |…
The repository is over its unit budget and a change may not raise the count: the case of the seat mesh-grants
(ADR 0582 clause 1) runs as a subtest of TestTheDeliverySeatPromisesChecks instead of a function of its own.

(issue 774, 774/S1)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:50:37 +01:00
jschoubben bbfda29b49 The watchman hears the lease write's events and the stream ASSIGNMENTS deleted
ADR 0582 clauses 4 and 6 have mesh-grants say lease-altered and removal-failed "as a finding the watchman
raises". WatchmanHears, the one list its grant and its subscriptions come from, gains the seat mesh-grants'
three events and the advisory of ASSIGNMENTS deleted (which, with the controller's consumer deleted that it
hears already, clears a failed removal). Core subjects, read-only, no durable consumer.

Test: rows added to the watchman's subjects in TestTheCopiedComposerComposesAsTheControllers; no new function.

(issue 774, 774/S22)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:37:04 +01:00
jschoubben 3ed41f02d5 The keep list holds the three kept to the write, since this delivery drops every other read of the controller
ADR 0571 §13: the lease write's own delivery drops every entry but the builder's artifacts-gone, the artifact
sweep's collect and the engine's following of the controller's user list. Every module of this group stops
its read, invoke or consume of the controller (pacman, the keeper, mesh-config, the hand-act log's readers, the
watcher, messenger, the assigner, mesh-grants), so the list shrinks to the three and a read put back is
refused in either phase. Supersedes the keep of pacman's read at mesh-delivery 4a84370.

Tests: rows of TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController changed (the keeper's read put back
is refused; the sweep's collect passes); no new test function.

(issue 774, 774/S30)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:33:34 +01:00
jschoubben 09a5eb5783 Delete PlacementProposalChange.BeforeLease: the assigner no longer keeps the controller's assignments in step
It said which placement changes waited for the lease write while the assigner called the controller's assign,
unassign, pin and unpin (ADR 0567 §8). Since the assigner calls no controller verb (774/S25), nothing reads it.

Test: its table in TestPlacement goes with it.

(issue 774, 774/S25)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:32:13 +01:00
jschoubben a4ed4e6742 Stop hearing mesh.assignment: a process follows its membership from the grants part's state alone
ADR 0571 clause 10: the grants part publishes memberships on mesh.assignment until the lease write and stops
then. FollowMembership now reads and watches its own key of the state memberships and nothing else, and refuses
to follow, naming the state, when it cannot read it (there is nothing else to follow, and a silent follow of
nothing is the fault). A module's principal and the runtime lose their subscribe on mesh.assignment; the grants
part's publish grant stays until the write (S21 stops the publishing).

Tests: TestFollowMembershipHearsWhatIsIssuedToTheAccount (integration) edited: no state is a refusal, a
membership put in the state is applied, one published on the subject is not; run locally against its
embedded bus. Rows added to TestTheCopiedComposerComposesAsTheControllers: nobody subscribes mesh.assignment,
and the controller's golden grant of it is the one row the copy now lacks. No new test function.

(issue 774, 774/S5)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:19:15 +01:00
jschoubben d7a008bee5 Move the controller-dependence judge into mesh-sdk, so three repositories run the same lease-write check
ADR 0571 §13 names a test in mesh-catalog, mesh-delivery and mesh-tools that refuses a manifest depending on
the controller beyond the lease and the entries kept. The judge lived in mesh-grants (leasecheck.go); it moves
here whole (the kinds, the phases, KeepList, EndedBy, Dependencies, Judge) with ManifestRefusals(manifests,
phase) for those tests. Kept is renamed KeptDependency (catalogue already has a Kept), and a gap is Unread.
mesh-grants re-exports it in its own commit; the keep list is the one at mesh-delivery 4a84370.

Test: TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController, moved from mesh-grants'
leasecheck_test.go (its manifest rows and the keep-list test, which mesh-grants deletes), with the
conditions row of 774/S4/AC1 added; not a new test.

(issue 774, 774/S4)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:17:13 +01:00
jschoubben feadebd331 Let the lease key name its ask and time, and the user-list key say the lease is handed
ADR 0582 clause 3 writes the key holder "with the why, the ask and the time": LeaseHolder gains ask and at,
which LeaseHandedAway ignores (it reads handed-to alone). The grants part's user-list key gains lease-handed,
so the bus node's engine follows the grants part's list alone once the lease is handed (ADR 0571 clause 8)
and never falls back to the controller's; a key written before reads false.

Tests: a row in the lease table of TestTheCopiedComposerComposesAsTheControllers; the old key (shaped as
EncodeGrantsUserList writes it today) and the flag's round trip added to the existing user-list case in
delivery_test.go; no new test function.

(issue 774, 774/S3)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:15:20 +01:00
jschoubben 6f483725e9 Compose the lease write's cut, so a controller started after the write fails on the lease
ADR 0582 clause 3 composes the bus's user list without the controller's write on the lease key and update of
the lease's bucket. Records.LeaseCut, set by the grants part from the write's second step, drops the
controller's $KV grant on the lease, denies it the bucket's update, purge, delete and message delete (its
$JS.API.> reaches them), and lets the grants part write the key holder alone. The bus module's tools account
may read and update the lease's bucket, for the expiry tool the write and the hand-back call. Without the cut
the lists equal the controller's golden.

Tests: rows added to TestTheCopiedComposerComposesAsTheControllers' tables (the cut on and off; the tools
account's lease grants); no new test function.

(issue 774, 774/S2)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:14:44 +01:00
jschoubben fdbb3d5784 Add the seat mesh-grants, so the grants part can claim the lease write as a seat verb
ADR 0582 clause 1 makes the lease write a verb of the seat mesh-grants: lease-write and lease-hand-back, each
taking a why, and the events lease-handed, lease-altered and removal-failed. The indexer's judge reads seats
through catalogue.SeatNamed, so it accepts mesh-grants' claim once it pins this head.

Test: TestTheGrantsSeatPromisesTheLeaseWrite (new), paid by merging the two ssh tests of filtering_test.go into
TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned.

(issue 774, 774/S1)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:12:08 +01:00
mesh-tools ea8c50136c Merge pull request 'Work out a node's declared packages and compose a node once over one read (ADR 0565 §6, ADR 0580; issues 1953, 1954)' (#155) from feat/node-packages into main 2026-11-01 17:02:22 +00:00
jschoubben b2a27ed5ce Merge branch 'feat/compose-once' of /home/jochen/projects/novox/mesh-sdk-compose into feat/node-packages
mesh/delivery merged: source.main moved to novox/mesh-sdk@ea8c50136cc7
mesh/delivery-group group feat/node-packages merged: every member is merged
mesh/check pass b2a27ed5cec4 on 621984614dc6 | unit 8060 run, 706 kept, 51 skipped | integration 8198 run, 749 kept, 56 skipped | over bound: unit, in…
2026-11-01 16:19:45 +01:00
jschoubben 5d26c658f5 Compose a node's plans once per read, resolving each machine once (issue 1954)
A node's plans took 2.6-13.6 s of CPU on the provisioner: one composition
resolved every machine of the mesh some two hundred times (who is on the
private network, and the rest of the mesh, worked out again on every
look), three compositions did so again each, and the holder composed
twice. A composer now works each out once over its unchanging Input;
Composing gives Resolve, Render and Generators one composer;
PlansHeldTwice composes the wanted and the newest builds over one read of
the bus, says where the time went by phase, and the states reader keeps a
build's take-in by its content. WatchedStates takes a process's own
writes into the copy at once, so a holder reads its own states from it.

Measured on a captured novox mesh: 11.7 s to 0.49 s per composition of an
unchanged node, plans and provisions identical on all four nodes.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 16:18:44 +01:00
mesh-tools 621984614d Merge pull request 'Agree a toolchain by strict majority; a node on another version sits out (ADR 0584, issue 1578)' (#154) from fix/toolchain-majority into main 2026-11-01 15:04:07 +00:00
jschoubben 0ef7c0caa4 Work out a node's declared packages and their difference with the mirror in the SDK, so each engine writes its own key (ADR 0565 clause 6, issue 1953)
The engine composes its node's wants itself, so the derivation the controller keeps
privately moves beside the layout: DeclaredPackagesOf gives the mirror's bytes from the
compositions, DiffDeclaredPackages what the node seat's verb packages compares until the
lease write, and EnginePackagesRead names the state as readers declare it (mesh-host.packages,
as mesh-host.data). Each engine may read the controller mirror's key of its own node alone.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 16:02:06 +01:00
jschoubben 67354f8e01 Name the agreed toolchain in a check ask and decline it on a node off that toolchain (ADR 0584, issue 1578)
mesh/delivery merged: source.main moved to novox/mesh-sdk@621984614dc6
mesh/delivery-group group fix/toolchain-majority merged: every member is merged
mesh/check pass 67354f8e019a on cad8250aaa8c | unit 7056 run, 243 kept, 16 skipped | integration 7194 run, 275 kept, 25 skipped | over bound: unit, in…
A check ask named no toolchain, so a node on the minority version could run it and its pass be recorded as run on the agreed one. CheckJob now carries the toolchain; a node on another declines it before it fetches, as for a build ask.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 15:33:43 +01:00
jschoubben 71be15f530 Decline a build ask for another toolchain instead of failing it (ADR 0584 clause 2, issue 1578)
With the toolchain agreed by majority, a node on the minority version can pull an ask for the agreed one. It now declines it (the issue 890 path) for the asker to put again, so it never fails a build.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 15:24:13 +01:00
jschoubben a6c6d936d5 Agree a toolchain by strict majority and name the nodes that sit out (ADR 0584, issue 1578)
One build node on another version stopped every build of the language. The agreed toolchain is now the strict majority's; a tie still waits and names the versions.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 15:20:26 +01:00
mesh-tools cad8250aaa Merge pull request 'A watched reader of bus states; the hand-act recorder records only through mesh-hand-acts (ADR 0580, design 47 L5)' (#153) from feat/l5-l7-main into main 2026-11-01 13:53:07 +00:00
jschoubben 790d1c82d0 A follower joining a watched state hears its current values, and a templates archive is fetched once
mesh/delivery merged: source.main moved to novox/mesh-sdk@cad8250aaa8c
mesh/delivery-group group feat/l5-l7-main merged: every member is merged
mesh/check pass 790d1c82d027 on da41823855a9 | unit 1964 run, 5697 kept, 30 skipped | integration 1999 run, 5829 kept, 38 skipped | over bound: unit, …
A read could start a state's watch before its follower joined, and a change landing in that gap woke nothing (review
of ADR 0580 §2): a joining follower now runs over the copy's current values before any later change. The watched
reader keeps each archive once per URL, and both caches are bounded.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 09:53:17 +01:00
jschoubben 8696aa9ba4 The hand-act recorder records through the seat alone, and the watched reader's reads are proved
The controller's hand-act and warranted are gone (issue 1953 L5b) and the bus refuses them, so the recorder no longer
falls back to them: an act the seat does not take is an error saying nothing was recorded. And ADR 0580 clause 1's
reader is shown reading a build by key each time and a manifest once per digest.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 09:53:17 +01:00
jschoubben db4eb3bbf4 One watched copy of the states a module reads, and the composer's reader over it
A whole state read key by key took the provisioner's composition of one node past 30 s and the assigner's compare past
its call bound (ADR 0580 clause 1): each state is now one watch's current values, kept by its changes; a build is read
by key and a manifest once per digest.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 09:53:17 +01:00
mesh-tools da41823855 Merge pull request 'mesh-sdk keeps the manifest contract and a lenient decoder; the judge leaves it (ADR 0578, 0579, 0573 §2)' (#152) from feat/judge-once into main 2026-11-01 07:53:58 +00:00
59 changed files with 3583 additions and 258 deletions
+27 -11
View File
@@ -25,7 +25,7 @@ type StepFunc func(ctx context.Context, args map[string]any) (any, error)
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
type AskSteps struct {
Fetch, Build, Copy, Digest, Test StepFunc
// Toolchain is the toolchain the module builds with now; a build ask naming another is refused. Nil checks nothing
// Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing
// (an image or an archive names none).
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is
@@ -107,19 +107,32 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa
return r, f, nil
}
// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when
// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and
// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2).
func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) (*delivery.ToolchainUsed, error) {
if want == nil || s.Toolchain == nil {
return nil, nil
}
tc, err := s.Toolchain(ctx)
if err != nil {
return nil, err
}
if tc.Version != want.Version || tc.Recipe != want.Recipe {
return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)},
Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain",
want.Version, want.Recipe, tc.Version, tc.Recipe)}
}
return &tc, nil
}
func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) {
answer := delivery.BuildAnswer{}
if job.Toolchain != nil && s.Toolchain != nil {
tc, err := s.Toolchain(ctx)
if err != nil {
return nil, err
}
if tc.Version != job.Toolchain.Version || tc.Recipe != job.Toolchain.Recipe {
return nil, Refuse("the ask is for %s (recipe %s) and this node builds with %s (recipe %s)",
job.Toolchain.Version, job.Toolchain.Recipe, tc.Version, tc.Recipe)
}
answer.Toolchain = &tc
tc, err := s.onToolchain(ctx, job.Toolchain)
if err != nil {
return nil, err
}
answer.Toolchain = tc
var f fetched
defer func() { s.release(f.Workspace) }()
for _, a := range job.Artifacts {
@@ -181,6 +194,9 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
}
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
if _, err := s.onToolchain(ctx, job.Toolchain); err != nil {
return nil, err
}
r, f, err := s.fetch(ctx, job.Fetch)
if err != nil {
return nil, err
+9 -3
View File
@@ -103,7 +103,7 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
}
}
// A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again.
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
s := r.steps()
@@ -112,10 +112,16 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
}
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
var ref *Refusal
if !errors.As(err, &ref) || len(r.calls) != 0 {
var dec *Declining
if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 {
t.Fatalf("err %v, calls %v", err, r.calls)
}
// A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches.
_, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit},
Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
if !errors.As(err, &dec) || len(r.calls) != 0 {
t.Fatalf("a check ask: err %v, calls %v", err, r.calls)
}
}
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
+27
View File
@@ -538,6 +538,28 @@ func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) {
p.mu.Unlock()
}
// declineRun ends the step of an ask that began and then found this node should not run it (Declining), as decline does
// before it begins: refused as declined, the message dropped for its asker to put again, and the worker it came from left
// alone until the next pull's wait.
func (p *Puller) declineRun(msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step, d *Declining) {
result, _ := json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: d.Lacks})
step.Ended = now().UTC()
step.State, step.Reason, step.Result = delivery.StepRefused, d.Text, result
if err := writeEnd(p.Put, key, step); err != nil {
p.logf("the ask %s cannot be declined on %s, so it is given back: %v", ask.ID, p.Node, err)
_ = msg.Nak()
return
}
p.logf("the ask %s is declined on %s: %s", ask.ID, p.Node, d.Text)
_ = msg.Term()
p.mu.Lock()
if p.declined == nil {
p.declined = map[string]bool{}
}
p.declined[msg.Kind] = true
p.mu.Unlock()
}
func withKind(msg *Pulled, kind string) *Pulled {
if msg != nil {
msg.Kind = kind
@@ -625,6 +647,11 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
})
step = ph.end() // its end keeps the phase it ended in
close(done)
var dec *Declining
if errors.As(err, &dec) && ctx.Err() == nil {
p.declineRun(msg, ask, key, step, dec)
return
}
if ctx.Err() != nil {
// The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs.
return
+15
View File
@@ -230,6 +230,21 @@ func TestPullDropsWhatIsNoAsk(t *testing.T) {
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
go q.Pull(ctx)
waitFor(t, "the placed ask acked where its need is met", func() bool { a, _, _ := has.Fates(); return len(a) == 1 })
// A run that finds its node is not on the agreed toolchain declines the ask after it began (ADR 0584 clause 2); a node on it builds.
sit, sitSteps := NewFakeAsks(), newSteps()
toolchain := testAsk(delivery.AskDelivery)
_ = sit.PublishAsk(toolchain)
s2 := &Puller{Source: sit, Language: delivery.AskGo, Node: "ace", Put: sitSteps.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
return nil, &Declining{Text: "the ask is for 26.13.0 and this node builds with 26.12.1", Lacks: []string{"the agreed toolchain 26.13.0 (recipe 3)"}}
}}
go s2.Pull(ctx)
waitFor(t, "the ask termed on the node that sits out", func() bool { _, tm, _ := sit.Fates(); return len(tm) == 1 })
sk, _ := delivery.AskStepKey("ace", toolchain.ID)
ss, _ := sitSteps.get(sk)
if d, ok := delivery.DeclinedOf(ss); !ok || len(d.Lacks) != 1 || !strings.Contains(ss.Reason, "26.12.1") {
t.Fatalf("the sitting-out step = %+v", ss)
}
}
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
+11
View File
@@ -16,3 +16,14 @@ func (r *Refusal) Error() string { return r.Text }
// Refuse is a Refusal with its text formatted.
func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} }
// Declining is a step a node does not run because another node should: it ends refused as declined (delivery.StepDeclined),
// the ask's message is dropped, and the asker puts the ask again under a new id (issue 890). A build ask naming a
// toolchain other than this node's is one (novox/hq ADR 0584 clause 2): a node that is not in the toolchain majority
// sits out and never fails a build.
type Declining struct {
Text string
Lacks []string // what this node lacks, in words the operator reads
}
func (d *Declining) Error() string { return d.Text }
+4
View File
@@ -219,6 +219,10 @@ type LeaseHolder struct {
// HandedBy is the credential that wrote it (the operator's), and Why its reason.
HandedBy string `json:"handed-by,omitempty"`
Why string `json:"why,omitempty"`
// Ask is the operator's ask whose warrant handed it (novox/hq ADR 0582 clause 3: "with the why, the ask and the
// time"), and At when it was written. Neither turns the lease: LeaseHandedAway reads HandedTo alone.
Ask string `json:"ask,omitempty"`
At time.Time `json:"at,omitzero"`
}
// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and
+234 -7
View File
@@ -3,6 +3,7 @@ package buscompose
import (
"encoding/json"
"errors"
"maps"
"os"
"path/filepath"
"reflect"
@@ -96,6 +97,9 @@ func engineBeyond(user, g string) bool {
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
// And, read, the controller's mirror's key of its node, until the lease write (novox/hq ADR 0565 clause 6).
strings.Contains(g, "KV_"+DeclaredPackagesBucket) && !strings.HasPrefix(g, "$KV.") &&
(!strings.Contains(g, "$KV.") || strings.HasSuffix(g, "$KV."+DeclaredPackagesBucket+"."+node)) ||
// And its own health report (issue 774).
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
@@ -158,8 +162,11 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
intended := beyondTheControllers(user)
for g := range was {
// A runtime's direct get of its membership from the assignments stream went with the stream (research 072
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7).
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+strings.SplitN(user, ".", 2)[0]+".") {
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7). And it no longer hears
// the subject, where the grants part stops publishing at the lease write (ADR 0571 clause 10, issue 774).
node := strings.SplitN(user, ".", 2)[0]
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+node+".") &&
!strings.HasPrefix(g, "mesh.assignment."+node+".") {
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
}
}
@@ -413,6 +420,156 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
}
}
})
// 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one
// inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list
// is the one above (AC2: the controller's golden lists, unchanged).
t.Run("a node rotating has two logins of one node", func(t *testing.T) {
vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}}
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
users, err := Users(Records{Nodes: []string{"ace", "g14"},
Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}},
NodeLogins: map[string]delivery.NodeLogin{
"g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since},
// A node that rotated before and rotates no more logs in as its second login alone.
"ace": {Node: "ace", Login: "node.ace--b", Since: since}}})
if err != nil {
t.Fatal(err)
}
perms := map[string]Permissions{}
for _, u := range users {
if u.Kind == KindNode {
p, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
perms[u.Username()] = p
}
}
if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil {
t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms)))
}
// Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3).
own := func(p Permissions, login string) Permissions {
swap := func(in []string) []string {
out := make([]string, len(in))
for i, s := range in {
out[i] = strings.ReplaceAll(s, CallerToken(login), "<caller>")
}
return out
}
return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe),
AllowResponses: p.AllowResponses}
}
if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) {
t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b)
}
for login, p := range perms {
node, _ := delivery.LoginNode(login)
if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node {
t.Errorf("%s's inbox: %v", login, p.Subscribe)
}
}
// AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name.
get := func(key string) string {
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
}
confirm := func(login string) string {
return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login)
}
for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false,
confirm("node.g14--b"): true, confirm("node.g14"): false} {
if MayPublish(perms["node.g14--b"], subject) != want {
t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want)
}
}
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) {
t.Error("a node reads node-logins while the vault does not rotate")
}
// And nothing but the grants part names node-logins among its reads.
if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) {
t.Error("node-logins' named readers")
}
})
// 774/S40/AC1 (ADR 0587 §4, §6): from the lease write an enrolling node's user may publish the redeem call in its
// own name and nothing else, and subscribe its own inbox alone; before it, the controller's subject beside.
t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) {
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
for _, handed := range []bool{true, false} {
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"}, LeaseHandedAway: handed,
Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}})
if err != nil {
t.Fatal(err)
}
var lab *Principal
for i := range users {
if users[i].Username() == "enrol.lab" {
lab = &users[i]
}
}
if lab == nil {
t.Fatal("no user enrol.lab")
}
perms, err := PermissionsFor(*lab)
if err != nil {
t.Fatal(err)
}
want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
if !handed {
want = []string{"mesh.control.enrol", "mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
}
if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) ||
perms.AllowResponses {
t.Errorf("handed %v: enrol.lab publishes %v, subscribes %v, answers %v", handed, perms.Publish, perms.Subscribe,
perms.AllowResponses)
}
}
// No holder serving redeem and the lease handed: the user may enrol nowhere, refused by name.
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab", LeaseHandedAway: true}); err == nil {
t.Error("an enrolment user that may enrol nowhere was composed")
}
})
// 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the
// vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two,
// however wide its grant; and its bucket is read by the grants part alone besides it.
t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) {
vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}}
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"}
users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": {
{Module: "mesh-vault", Holds: []Seat{vault}},
// One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner.
{Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls,
State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}},
{Module: "shop", Invokes: calls[:2]},
{Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}})
if err != nil {
t.Fatal(err)
}
call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) }
for _, u := range users {
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
name := u.Username()
for _, verb := range []string{"enrolment-token", "node-account"} {
enrols := name == "ace.mesh-enrolment"
if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) {
t.Errorf("%s may call %s: %v", name, verb, !enrols)
}
}
if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) ||
!MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) {
t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish)
}
if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") {
t.Errorf("%s writes the enrolments", name)
}
}
if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) {
t.Error("the enrolments' named readers")
}
})
t.Run("the controller's golden server configuration", func(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
got, err := Compose(
@@ -558,6 +715,9 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
}{
{"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false},
{"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true},
// ADR 0582 clause 3 (774/S3): the write names the ask and the time, which turn nothing.
{"handed to mesh-grants with its ask and time", `{"handed-to":"mesh-grants","handed-by":"person.jochen","why":"w",` +
`"ask":"lease-write-1","at":"2026-11-02T10:00:00Z"}`, true, nil, true},
{"expired or deleted", ``, false, nil, false},
{"unreadable", ``, false, errors.New("refused"), false},
{"not a holder", `[1]`, true, nil, false},
@@ -633,6 +793,48 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
}
}
// ADR 0582 clause 3 (issue 774, 774/S2): the lease write's cut takes from the controller the lease's key and every
// update, purge and delete of its bucket, and gives the grants part the key holder alone; without it nothing
// changes (the golden lists above are composed without it).
lease := "$KV." + LeaseBucket + ".holder"
for _, cut := range []bool{false, true} {
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {{Module: GrantsModule}}},
LeaseCut: cut})
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
kind Kind
subject string
may bool
}{
{KindController, lease, !cut},
{KindController, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, !cut},
{KindController, "$JS.API.STREAM.PURGE.KV_" + LeaseBucket, !cut},
{KindController, "$JS.API.STREAM.DELETE.KV_" + LeaseBucket, !cut},
{KindController, "$JS.API.STREAM.MSG.DELETE.KV_" + LeaseBucket, !cut},
// It still reads the lease, and keeps its other buckets.
{KindController, "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder", true},
{KindController, "$KV." + CallsBucket + ".x", true},
{KindController, "$JS.API.STREAM.UPDATE.KV_" + CallsBucket, true},
{KindModule, lease, cut},
{KindModule, "$KV." + LeaseBucket + ".other", false},
{KindModule, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, false},
} {
for _, u := range users {
if u.Kind != c.kind || (c.kind == KindModule && u.Module != GrantsModule) {
continue
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatalf("cut %v: %s: %v", cut, u.Username(), err)
}
if MayPublish(perms, c.subject) != c.may {
t.Errorf("cut %v: %s may publish %s: %v, want %v", cut, u.Username(), c.subject, !c.may, c.may)
}
}
}
}
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
healthChecks(t)
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
@@ -740,7 +942,9 @@ func healthChecks(t *testing.T) {
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts",
// Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write.
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS",
// ADR 0582 clause 3 (issue 774, 774/S2): the lease's bucket's expiry, read and set by its tool.
"$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
}
mayNot := map[string][]string{
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
@@ -783,8 +987,13 @@ func healthChecks(t *testing.T) {
// outside the bus module).
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts",
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X"},
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X",
// It updates the lease's bucket alone, and neither purges nor deletes it nor writes its key (ADR 0582 clause 3).
"$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS",
"$JS.API.STREAM.PURGE.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease",
"$KV.mesh-controller_lease.holder"},
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1",
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
}
@@ -815,12 +1024,27 @@ func healthChecks(t *testing.T) {
MayPublish(perms, "$KV.mesh-host_health.b")) {
t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish)
}
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) {
// ADR 0565 clause 6: and it reads the controller's mirror's key of its own node alone, and writes none of it.
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") ||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a") ||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.b") ||
MayPublish(perms, "$KV.mesh-controller_declared-packages.a")) {
t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish)
}
if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") {
t.Errorf("%s writes the assigner's policies", u.Username())
}
// ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from
// the grants part's state.
if MaySubscribe(perms, "mesh.assignment.a.restic") {
t.Errorf("%s hears mesh.assignment", u.Username())
}
}
// Nor the runtime carrying the node's modules, which reads each one's membership from the state.
if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule,
Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") ||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") {
t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err)
}
// 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as
// long as the log (ADR 0297).
@@ -878,7 +1102,10 @@ func healthChecks(t *testing.T) {
// It hears its watchdogs' words on core subjects (Limits 3, 11).
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak",
// ADR 0582 clauses 4, 6 (issue 774, 774/S22): the lease write's events, and ASSIGNMENTS deleted.
"mesh.seat.mesh-grants.event.lease-altered", "mesh.seat.mesh-grants.event.removal-failed",
"mesh.seat.mesh-grants.event.lease-handed", "$JS.EVENT.ADVISORY.STREAM.DELETED.ASSIGNMENTS"} {
if !MaySubscribe(perms, subject) {
t.Errorf("the watchman may not hear %s", subject)
}
+3 -1
View File
@@ -74,9 +74,11 @@ func engineGrants(p Principal) (pub, sub []string) {
if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil {
pub = append(pub, own...)
}
// And its node's declared packages, its own key alone (ADR 0565 clause 6).
// And its node's declared packages, its own key alone (ADR 0565 clause 6); and, read, the controller's mirror's key
// of its node, which the node seat's verb packages compares it with until the lease write ends the mirror.
if safeSubject.MatchString(p.Node) {
pub = append(pub, EnginePackagesGrants(p.Node)...)
pub = append(pub, readGrants(DeclaredPackagesBucket, p.Node)...)
}
return append(pub, tp...), append(sub, ts...)
}
+7 -1
View File
@@ -114,7 +114,7 @@ func WatchmanDeadLetterGrants(sources []string) []string {
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
// connections (S9), the provisioner clients' reports (S21), and the dead letters kept. Never the bus's API audit
// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
// them (the lead, 2026-10-29).
func WatchmanHears() []string {
@@ -129,6 +129,12 @@ func WatchmanHears() []string {
"mesh.mod.nats.event.bus.connection-closed",
ProvAppliedSubjects,
deadLetterPrefix + ">",
// And the lease write's (ADR 0582 clauses 4, 6; issue 774): the seat mesh-grants' three events, and the stream
// ASSIGNMENTS deleted, which with the controller's consumer deleted clears a removal that failed.
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseHanded,
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseAltered,
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventRemovalFailed,
"$JS.EVENT.ADVISORY.STREAM.DELETED." + AssignmentsStream,
}
}
+41
View File
@@ -0,0 +1,41 @@
package buscompose
import "slices"
// **The lease write's grants** (novox/hq ADR 0582 clause 3, issue 774). Composed with Records.LeaseCut, the list takes
// from the controller its write of the lease's key and every update, purge and delete of the lease's bucket, and gives
// the grants part the key `holder`, which it creates naming itself and deletes at a hand-back (clause 5). Whatever the
// records, the bus module's tools account may read and update the lease's bucket, for the tool that removes and
// restores its expiry; no other bucket's.
// leaseStream is the lease bucket's stream.
var leaseStream = "KV_" + LeaseBucket
// LeaseHolderWrite is the grants part's write of the lease's one key, from the cut.
func LeaseHolderWrite() string { return "$KV." + LeaseBucket + "." + leaseKey }
// LeaseCutDenied are the controller's subjects of the lease's bucket that its `$JS.API.>` reaches and the cut refuses:
// the bucket's update (its expiry), purge and delete, and a message's delete (the key's last value).
func LeaseCutDenied() []string {
return []string{"$JS.API.STREAM.UPDATE." + leaseStream, "$JS.API.STREAM.PURGE." + leaseStream,
"$JS.API.STREAM.DELETE." + leaseStream, "$JS.API.STREAM.MSG.DELETE." + leaseStream}
}
// LeaseExpiryGrants are the bus module's tools account's read and update of the lease's bucket, for its expiry.
func LeaseExpiryGrants() []string {
return []string{"$JS.API.STREAM.INFO." + leaseStream, "$JS.API.STREAM.UPDATE." + leaseStream}
}
// writesTheLease is the controller holding the lease, or, from the cut, the grants part writing its key holder alone.
func writesTheLease(p Principal, pattern string) bool {
if p.Kind == KindController {
return true
}
return p.Kind == KindModule && p.Module == GrantsModule && p.LeaseCut && pattern == LeaseHolderWrite()
}
// setsLeaseExpiry is the bus module's tools account updating the lease's bucket: the writers table's one exemption from
// stream definitions being the controller's beside ASSIGNMENTS' removal (ADR 0582 clause 3).
func setsLeaseExpiry(p Principal, pattern string) bool {
return p.Kind == KindProcess && len(p.Removes) > 0 && slices.Contains(LeaseExpiryGrants(), pattern)
}
+131 -10
View File
@@ -22,6 +22,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -201,6 +202,9 @@ type Principal struct {
// Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus
// (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them.
Removes []string
// LeaseCut is Records.LeaseCut on the controller and the grants part (novox/hq ADR 0582 clause 3): the controller
// loses its write of the lease, and the grants part may write the key `holder`.
LeaseCut bool
// DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
DeadLetterSources []string
@@ -250,6 +254,26 @@ type Principal struct {
// stream and name. Created only, never updated or deleted.
CreatesConsumers []ModuleConsumer
// Login is a node principal's login when it is not `node.<n>`: its second login, `node.<n>--b`, composed beside or
// in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted
// the node's permissions and the node's inbox `_INBOX.node.<n>`; what it calls, it calls in its own name, so the
// vault tells the login that confirms a rotation from the one it replaces (§3).
Login string
// RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins,
// and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight,
// §3). Set while the vault's holder serves rotate-node.
RotatesItsLogin bool
// Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else
// of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's
// enrolment subject from it, which is served no more from the lease write (§6).
Redeems bool
LeaseHandedAway bool
// VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but
// the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1).
VaultEnrols bool
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
// and never appears here: this file is written to a node's disk and read by a server, and a
// secret that can be read from a configuration file is a secret with a wider blast radius
@@ -300,6 +324,22 @@ func ControllerOnly() []string {
return out
}
// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587
// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired.
var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb},
{Seat: VaultSeat, Verb: delivery.NodeAccountVerb}}
// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the
// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them).
func EnrolmentOnly() []string {
var out []string
for _, v := range VerbsTheEnrolmentAsksTheVault {
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>",
"mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>")
}
return out
}
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
func MayPublish(perms Permissions, subject string) bool {
for _, d := range perms.PublishDeny {
@@ -377,6 +417,33 @@ var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"}
// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says.
var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb}
// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node
// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a
// node-engine confirms a switch with, in its new login's name (§3).
var (
VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb}
VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb}
)
// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol.<node> (novox/hq ADR 0587 §4,
// §6).
var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem}
// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its
// own name.
func EnrolmentRedeemCall(p Principal) string {
return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username())
}
// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched,
// and no other (novox/hq ADR 0583 §1 insight).
func NodeLoginsReads(node string) []string {
if !safeSubject.MatchString(node) {
return nil
}
return readGrants(delivery.NodeLoginsBucket, node)
}
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
@@ -525,6 +592,9 @@ func (p Principal) Username() string {
// never the module's own name alone, which is a different account with a different secret.
return p.Node + "." + p.Module + "." + p.Process
case KindNode:
if p.Login != "" {
return p.Login
}
return "node." + p.Node
case KindController:
return "controller"
@@ -584,7 +654,14 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
// inbox is derived from its own identity and its permissions name that prefix and no other.
func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
//
// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as.
func (p Principal) inbox() string {
if p.Kind == KindNode {
return "_INBOX.node." + p.Node + ".>"
}
return "_INBOX." + p.Username() + ".>"
}
// Permissions is what a principal may publish and subscribe, and whether it may answer.
type Permissions struct {
@@ -668,6 +745,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
if err != nil {
return Permissions{}, err
}
// And the lease's bucket's expiry, read and set, by the bus module's tool the grants part calls at the lease
// write and its hand-back (novox/hq ADR 0582 clauses 3, 5): the one bucket whose stream it may update.
pub = append(pub, LeaseExpiryGrants()...)
sort.Strings(pub)
if err := CheckWriters(p, pub); err != nil {
return Permissions{}, err
@@ -801,6 +881,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
if bucket == delivery.HaveBucket {
continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1)
}
if bucket == LeaseBucket && p.LeaseCut {
continue // the lease write took it (novox/hq ADR 0582 clause 3): a controller started fails on the lease
}
pub = append(pub, "$KV."+bucket+".>")
}
// **And the object bucket a declaration's large files travel in** (novox/hq ADR 0486 §2, issue 1697): the
@@ -880,7 +963,20 @@ func PermissionsFor(p Principal) (Permissions, error) {
"enrolment: a token is issued for a node record, and that record's name is " +
"the token's id")
}
pub = []string{"mesh.control.enrol"}
// **From the lease write a node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6): its
// user calls redeem in its own name, while the seat's holder serves it, and the controller's enrolment subject is
// kept only while the controller holds the lease.
pub = nil
if !p.LeaseHandedAway {
pub = append(pub, "mesh.control.enrol")
}
if p.Redeems {
pub = append(pub, EnrolmentRedeemCall(p))
}
if len(pub) == 0 {
return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: the lease is handed away and "+
"no holder of %s serves %s", p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb)
}
sub = []string{p.inbox()}
case KindNode:
@@ -994,6 +1090,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
}
// And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other
// key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3).
if p.RotatesItsLogin {
pub = append(pub, NodeLoginsReads(p.Node)...)
pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+
CallerToken(p.Username()))
}
// And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3).
ep, es := engineGrants(p)
pub = append(pub, ep...)
@@ -1026,6 +1129,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...)
// And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7).
pub = append(pub, GrantsUserListWrites()...)
// And, from the lease write's cut, the lease's one key, which it creates naming itself and deletes at a
// hand-back (novox/hq ADR 0582 clauses 3, 5): never the bucket's other keys or its stream.
if p.LeaseCut {
pub = append(pub, LeaseHolderWrite())
}
}
// The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a).
if p.Module == ViewModule {
@@ -1061,10 +1169,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
sub = append(sub, announcing(announced...)...)
// Its own membership (ADR 0160): the one subject a runtime derives for itself, heard live. Nothing else's.
sub = append(sub, MembershipSubject(p.Node, p.Module))
// And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): its one read of it,
// since the assignments stream's direct get went with the stream (research 072 row 33).
// Its own membership (ADR 0160), from the grants part's state memberships, its own key (novox/hq ADR 0571 clause
// 7): its one read of it, since the assignments stream's direct get went with the stream (research 072 row 33),
// and no longer heard on its subject, where the grants part stops publishing at the lease write (ADR 0571 clause
// 10, issue 774).
pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...)
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
@@ -1226,10 +1334,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
}
// Every assigned module's membership on this node (ADR 0160): one per module, heard live and read from the
// grants part's state memberships (ADR 0571 clause 7). This node's and no other's — the one token
// that varies is the module, so the pattern is the machine's own assignments.
sub = append(sub, "mesh.assignment."+p.Node+".*")
// Every assigned module's membership on this node (ADR 0160): one per module, read and watched from the
// grants part's state memberships (ADR 0571 clause 7), and no longer heard on `mesh.assignment`, where the grants
// part stops publishing at the lease write (ADR 0571 clause 10, issue 774). This node's and no other's — the
// one token that varies is the module, so the pattern is the machine's own assignments.
pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...)
// And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's
// name and no other's, on core NATS like the host's.
@@ -1408,6 +1516,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
// What the controller alone may publish is denied to everybody else whose grant reaches it.
var deny []string
// And from the lease write's cut, the lease's bucket to the controller, whose `$JS.API.>` reaches it (novox/hq ADR
// 0582 clause 3): a controller started by mistake fails on the lease instead of taking it back.
if p.Kind == KindController && p.LeaseCut {
deny = append(deny, LeaseCutDenied()...)
}
if p.Kind != KindController {
for _, only := range ControllerOnly() {
for _, a := range pub {
@@ -1421,6 +1534,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
}
// And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1).
if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) {
for _, only := range EnrolmentOnly() {
if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) {
deny = append(deny, only)
}
}
}
return Permissions{
Publish: pub,
PublishDeny: deny,
+6 -1
View File
@@ -104,7 +104,12 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {},
// ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys
// (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here.
delivery.EngineDataRead: {health.DiskLoadModule}}
delivery.EngineDataRead: {health.DiskLoadModule},
// ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its
// machine principal (NodeLoginsReads), never through `reads`.
delivery.NodeLoginsRead: {GrantsModule},
// ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part.
delivery.EnrolmentsRead: {GrantsModule}}
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
// that names none.
+58 -8
View File
@@ -2,10 +2,12 @@ package buscompose
import (
"fmt"
"git.novox.be/novox/mesh-sdk/go/announce"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Every user the composed file should contain, derived from what the mesh knows.
@@ -129,7 +131,8 @@ type Records struct {
Assigned map[string][]Declared
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
// answer goes to is scoped to the token and a shared one is one machine reading another's
// sealed credentials (design 25 §6).
// sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or
// redeeming (novox/hq ADR 0587 §4), the controller from its store.
Enrolling []string
// People is each person's name against the tools they may invoke, `*` for an administrator.
People map[string][]string
@@ -145,9 +148,22 @@ type Records struct {
// grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account.
// Nil where its manifest was not read.
Engine *Declared
// LeaseHandedAway says the controller's lease names a holder that is no controller (ADR 0582 §3), as the grants part
// reads it: an enrolment user then enrols through the seat mesh-enrolment alone (novox/hq ADR 0587 §6). The
// controller's composer never sets it. It is not LeaseCut: the cut is composed before the holder is written, so the
// grants part may write it, and outlasts the holder at a hand-back; the two are true together only in between.
LeaseHandedAway bool
// NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one
// rotating, each composed as a user of the node's permissions. A node absent logs in as `node.<n>` alone.
NodeLogins map[string]delivery.NodeLogin
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
Announced map[string]announce.States
// LeaseCut composes the list of the lease write (novox/hq ADR 0582 clause 3, issue 774): the controller may neither
// write the lease's key nor update, purge or delete its bucket, and the grants part may write the key `holder`.
// Set by the grants part from the write's second step until a hand-back composes without it (clause 5); the
// controller's composer never sets it.
LeaseCut bool
}
// Users is every user the composed file should contain, in the order it will be written.
@@ -157,7 +173,7 @@ type Records struct {
// correct.
func Users(r Records) ([]Principal, error) {
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes), LeaseCut: r.LeaseCut}}
served := servedReadsOf(r)
logs := DeclaredLogs(r)
@@ -175,10 +191,14 @@ func Users(r Records) ([]Principal, error) {
}
checks = append(checks, d.Checks...)
}
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)})
// **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too,
// each with the node's permissions.
for _, login := range nodeLoginsOf(r, node) {
out = append(out, Principal{Kind: KindNode, Node: node, Login: login, WitnessesController: witness, Checks: checks,
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)})
}
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
// per machine that runs it, delivered to the module running it.
for _, d := range r.Assigned[node] {
@@ -246,6 +266,7 @@ func Users(r Records) ([]Principal, error) {
}
if d.Module == GrantsModule {
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
out[len(out)-1].LeaseCut = r.LeaseCut
grants = len(out) - 1
}
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
@@ -269,7 +290,8 @@ func Users(r Records) ([]Principal, error) {
}
}
for _, node := range sortedCopy(r.Enrolling) {
out = append(out, Principal{Kind: KindEnrolment, Node: node})
out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: servesVerb(r, EnrolmentRedeem),
LeaseHandedAway: r.LeaseHandedAway})
}
for _, person := range sortedNames(r.People) {
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
@@ -280,6 +302,13 @@ func Users(r Records) ([]Principal, error) {
out = append(out, p)
}
// The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1).
if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) {
for i := range out {
out[i].VaultEnrols = true
}
}
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
if grants >= 0 {
out[grants].CreatesConsumers = AccountConsumers(out)
@@ -379,6 +408,27 @@ func asksSeatOf(r Records) []Seat {
return nil
}
// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.<n>`): the one its
// node-logins key says is in use and the one rotating, in that order; `node.<n>` alone when the key is absent or does
// not hold (a key naming another node's login is no login of this node's).
func nodeLoginsOf(r Records, node string) []string {
l, ok := r.NodeLogins[node]
if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) {
return []string{""}
}
login := func(name string) string {
if name == delivery.FirstLogin(node) {
return ""
}
return name
}
out := []string{login(l.Login)}
if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) {
out = append(out, login(l.Rotating))
}
return out
}
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
const VaultSeat = "mesh-vault"
+29 -4
View File
@@ -43,7 +43,7 @@ func createsDeclared(p Principal, pattern string) bool {
if isController(p, pattern) {
return true
}
if removesAssignmentsStream(p, pattern) {
if removesAssignmentsStream(p, pattern) || setsLeaseExpiry(p, pattern) {
return true
}
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
@@ -96,6 +96,19 @@ func ownModule(p Principal, pattern string) bool {
return false
}
// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it.
func moduleNamed(module string) func(Principal, string) bool {
return func(p Principal, _ string) bool {
switch p.Kind {
case KindModule, KindProcess:
return p.Module == module
case KindNodeTools:
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module })
}
return false
}
}
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the
// controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone.
func ownAnnouncer(p Principal, pattern string) bool {
@@ -158,7 +171,8 @@ var WritersTable = []WriterRow{
// subject is this machine's engine's alone.
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine},
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: writesTheLease,
Shared: "from the lease write's cut the controller writes it no more and the grants part writes its key holder alone (novox/hq ADR 0582 clause 3)"},
// The merges mesh-delivery announced, for a moved module's registration (ADR 0420 §7), written by the lease holder.
{State: "the merges mesh-delivery announced", Writer: "controller (lease holder)",
KeptIn: "key-value " + DeliveryMergesBucket, Others: "read at a moved module's registration",
@@ -188,7 +202,7 @@ var WritersTable = []WriterRow{
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
Writes: createsDeclared,
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33)"},
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33), and updates the lease's bucket for its expiry alone (novox/hq ADR 0582 clause 3)"},
{State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state",
Others: "the controller asks",
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
@@ -217,6 +231,16 @@ var WritersTable = []WriterRow{
Writes: ownModule},
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
Others: "—"},
// The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a
// compare-and-set at the revision read; the grants part reads them as a named reader.
{State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set",
KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry",
Others: "the grants part reads them (a named reader) and composes each enrol.<node> user from them",
Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)},
// Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it.
{State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket,
Others: "the grants part reads every key, each node's engine its own node's key alone",
Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)},
{State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest",
Others: "the build seat reads"},
// What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice,
@@ -288,7 +312,8 @@ var WritersTable = []WriterRow{
KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined,
Subjects: kvOf(PredefinedBucket), Writes: isController},
{State: "the declared-packages mirror", Writer: "controller (lease holder), from the composition of each node, only on a change",
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages,
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages +
", and each node's engine its node's key, until the lease write (ADR 0565 clause 6)",
Subjects: kvOf(DeclaredPackagesBucket), Writes: isController},
// What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own
// machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module
+238
View File
@@ -0,0 +1,238 @@
package catalogue
import (
"fmt"
"slices"
"sort"
"strings"
)
// **What still depends on the controller, and the lease write's check** (novox/hq ADR 0571 §13, ADR 0582 clause 2,
// issue 774): every declaration of a manifest other than the controller's on the controller (a read of one of its
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, a use of its seat), judged
// against the named, temporary list of what another accepted record keeps until the lease write. The grants part's
// grants_lease_check runs it over every manifest the indexer holds; ManifestRefusals runs it over a repository's
// manifests, in the test ADR 0571 §13 names in mesh-catalog, mesh-delivery and mesh-tools. A read of the lease key is no
// dependency: it names the lease's holder, whoever that is, and parts read it to learn that the write happened.
// ControllerLease is the controller's lease as a reader names it (`<module>.<state>`): no dependency.
const ControllerLease = ControllerSeatName + ".lease"
// The kinds of a dependency on the controller, as a manifest declares them.
const (
DependsRead = "read"
DependsInvoke = "invoke"
DependsConsume = "consume"
DependsUses = "uses"
DependsWaits = "waits"
)
// The phases of the lease write's check (ADR 0571 §13): before the lease write's own delivery the whole named list is
// allowed; that delivery drops every entry but the three kept to the write itself, and its check runs with those alone.
const (
PhaseBefore = "before-delivery"
PhaseWrite = "write"
)
// Dependency is one declaration of a manifest other than the controller's on the controller: a read of one of its
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, or a send to its seat.
type Dependency struct {
Module string `json:"module"`
// By is the account declaring it when it is not the module's own: `process <id>` or `tools <binary>`.
By string `json:"by,omitempty"`
Kind string `json:"kind"`
What string `json:"what"`
// Kept is the record that keeps it until the lease write, from the named list; empty when none does.
Kept string `json:"kept,omitempty"`
// ToTheWrite says it is one of the three kept to the write itself, which the lease write's own delivery keeps.
ToTheWrite bool `json:"to_the_write,omitempty"`
}
// KeptDependency is one entry of the named, temporary list ADR 0571 §13 allows: a read, invoke or consume another
// accepted record keeps until the lease write, with that record. ToTheWrite marks the three kept to the write itself.
type KeptDependency struct {
Module, Kind, What, Record string
ToTheWrite bool
}
// KeepList is the named list. Each entry names the clause that keeps it; a dependency not on it fails the check. It
// holds the three kept to the write itself and nothing else: the lease write's own delivery dropped every other entry
// (ADR 0571 §13; issue 774), so its check runs with these alone in either phase. The third, the bus node agent's
// following of the controller's user list (ADR 0571 §8, ADR 0547 §8), is no manifest's declaration but the
// node-engine's own, so no manifest matches it; it stays named here so the list reads as the record has it.
var KeepList = []KeptDependency{
{Module: "mesh-delivery-builder", Kind: DependsConsume, What: "mesh-controller.artifacts-gone", Record: "ADR 0571 §4, §13", ToTheWrite: true},
{Module: "artifact-store-tools", Kind: DependsInvoke, What: "seat:mesh-controller.collect", Record: "ADR 0571 §4, §13", ToTheWrite: true},
{Module: "node-engine", Kind: DependsRead, What: "the controller's user list of the bus module's composition", Record: "ADR 0571 §8, §13; ADR 0547 §8", ToTheWrite: true},
}
// EndedBy names, for a declaration no entry keeps, the record that already ended it, so a refusal says where it moved.
// The controller's `grants` is one: ADR 0571 §9 has no manifest read it from L7's delivery, which changes ADR 0567 §10
// and ADR 0547 §5's shadow compare, so neither the assigner's nor the grants part's read of it is kept.
var EndedBy = map[string]string{
DependsRead + " mesh-controller.grants": "ADR 0571 §9: no manifest reads the controller's grants; the grants part's key is the one read",
DependsInvoke + " seat:mesh-controller.hand-act": "ADR 0565 §11: every recorder calls mesh-hand-acts",
DependsInvoke + " seat:mesh-controller.warranted": "ADR 0565 §11: every recorder calls mesh-hand-acts",
DependsInvoke + " seat:mesh-controller.root-free": "ADR 0571 §11: each machine's engine answers root-free (seat:node-engine.root-free@machine)",
DependsInvoke + " seat:mesh-controller.held": "ADR 0571 §2: held is the seat mesh-artifact-sweep's",
}
// keptFor is the list's entry for one dependency, or nil.
func keptFor(d Dependency) *KeptDependency {
for i, k := range KeepList {
if k.Module == d.Module && k.Kind == d.Kind && k.What == d.What {
return &KeepList[i]
}
}
return nil
}
// onController says whether a name `<owner>.<rest>` is the controller's.
func onController(name string) bool { return strings.HasPrefix(name, ControllerSeatName+".") }
// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none:
// it is the console's grant to every tool, judged by no record as a dependency.
func invokesController(invoke string) bool {
if seat, _, ok := SeatInvoke(invoke); ok {
return seat == ControllerSeatName
}
return onController(invoke)
}
// Dependencies are every declaration on the controller in the manifests given, by module, each with the list's entry
// that keeps it. The controller's own manifest is not judged, nor a read of its lease key: it names the lease's holder,
// whoever that is, and parts read it to learn that the write happened (ADR 0571 §13).
func Dependencies(manifests map[string]Manifest) []Dependency {
var out []Dependency
add := func(module, by, kind, what string) {
d := Dependency{Module: module, By: by, Kind: kind, What: what}
if k := keptFor(d); k != nil {
d.Kept, d.ToTheWrite = k.Record, k.ToTheWrite
}
if !slices.Contains(out, d) {
out = append(out, d)
}
}
for name, m := range manifests {
if name == ControllerSeatName || m.Module == ControllerSeatName {
continue
}
for _, r := range m.Reads {
if onController(r) && r != ControllerLease {
add(name, "", DependsRead, r)
}
}
for _, i := range m.Invokes {
if invokesController(i) {
add(name, "", DependsInvoke, i)
}
}
for _, c := range m.Consumes {
if onController(c) {
add(name, "", DependsConsume, c)
}
}
for _, w := range m.Waits {
if onController(w) {
add(name, "", DependsWaits, w)
}
}
for _, u := range m.Uses {
if u == ControllerSeatName {
add(name, "", DependsUses, u)
}
}
for _, a := range m.BusAccounts() {
by := "process " + a.ID
if a.Tools {
by = "tools " + a.ID
}
for _, i := range a.Bus.Invokes {
if invokesController(i) {
add(name, by, DependsInvoke, i)
}
}
}
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Module != b.Module {
return a.Module < b.Module
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
if a.What != b.What {
return a.What < b.What
}
return a.By < b.By
})
return out
}
// Verdict is the lease write's check in one phase.
type Verdict struct {
Phase string `json:"phase"`
Passes bool `json:"passes"`
Reasons []string `json:"reasons"`
}
// Coverage is how much of the mesh's manifests the dependencies were derived from: the manifests read, the modules the
// indexer holds (-1 when its keys could not be listed, which Unread then names), and each manifest that could not be
// read.
type Coverage struct {
Read, Indexed int
Unread []Unread
}
// Unread is one manifest, or one listing of them, that could not be read, and why.
type Unread struct {
Input string `json:"input"`
Why string `json:"why"`
}
// Judge runs the lease write's check over the dependencies (ADR 0571 §13) in one phase: before the lease write's own
// delivery, every dependency must be on the named list; at the write, only the three kept to the write itself may stay.
// It passes only over every module the indexer holds: no manifest read, fewer read than the indexer holds, or one that
// could not be read fails it, since what was not read could hide a dependency. A lease already handed away leaves
// nothing to write.
func Judge(deps []Dependency, phase string, cov Coverage, handedAway bool, leaseSays string) Verdict {
v := Verdict{Phase: phase, Reasons: []string{}}
if handedAway {
v.Reasons = append(v.Reasons, "there is nothing to write: "+leaseSays)
}
switch {
case cov.Read == 0:
v.Reasons = append(v.Reasons, fmt.Sprintf("no manifest was read from the indexer (it holds %d modules): with nothing read, nothing on the controller can be judged absent", max(cov.Indexed, 0)))
case cov.Indexed >= 0 && cov.Read < cov.Indexed:
v.Reasons = append(v.Reasons, fmt.Sprintf("only %d of the %d modules the indexer holds had their manifest read, so what the others declare on the controller is not known", cov.Read, cov.Indexed))
}
for _, d := range deps {
who := d.Module
if d.By != "" {
who += " (" + d.By + ")"
}
switch {
case d.Kept == "":
why := fmt.Sprintf("%s declares %s %s, which no record keeps until the lease write (ADR 0571 §13)", who, d.Kind, d.What)
if ended := EndedBy[d.Kind+" "+d.What]; ended != "" {
why += "; ended by " + ended
}
v.Reasons = append(v.Reasons, why)
case phase == PhaseWrite && !d.ToTheWrite:
v.Reasons = append(v.Reasons, fmt.Sprintf("%s declares %s %s, kept by %s only until the lease write's own delivery, which drops it", who, d.Kind, d.What, d.Kept))
}
}
for _, g := range cov.Unread {
v.Reasons = append(v.Reasons, fmt.Sprintf("%s could not be read (%s), so what it declares on the controller is not known", g.Input, g.Why))
}
v.Passes = len(v.Reasons) == 0
return v
}
// ManifestRefusals is the lease write's check over a repository's manifests, by module name, in one phase (PhaseBefore
// or PhaseWrite): each reason the check gives, none when it passes. Every manifest given counts as read: the caller reads
// them from its own tree, and none given fails it.
func ManifestRefusals(manifests map[string]Manifest, phase string) []string {
return Judge(Dependencies(manifests), phase, Coverage{Read: len(manifests), Indexed: -1}, false, "").Reasons
}
+103
View File
@@ -0,0 +1,103 @@
package catalogue
import (
"slices"
"strings"
"testing"
)
// Issue 774 (ADR 0571 §13, ADR 0582 clause 2): the lease write's check judges every manifest's dependency on the
// controller against the named list, at the write (the three kept to it alone) and before its delivery. Moved with the
// judge from mesh-grants' leasecheck_test.go (TestTheLeaseCheckJudgesEveryDependencyOnTheController's manifest rows and
// TestTheKeepListNamesItsRecordsAndThreeToTheWrite), whose manifests were mesh-grants' fixtures; the conditions row is
// mesh-conditions' manifest at mesh-catalog ce3bedd5 (module.json:31-32).
func TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController(t *testing.T) {
toTheWrite := 0
for _, k := range KeepList {
if !strings.HasPrefix(k.Record, "ADR ") || k.Module == "" || k.What == "" {
t.Errorf("entry %+v names no record, module or declaration", k)
}
if k.ToTheWrite {
toTheWrite++
}
}
if toTheWrite != 3 || len(KeepList) != 3 {
t.Errorf("%d entries, %d kept to the write itself: ADR 0571 §13 keeps the three alone from the write's delivery", len(KeepList), toTheWrite)
}
type want struct {
deps []string // "<module> <kind> <what> <kept by>", kept by the record or "unkept"
write string // a reason the check at the write names, or "" when it passes
before string // a reason the check before the delivery names, or "" when it passes
}
for _, row := range []struct {
name string
manifests map[string]string
want want
}{
// 774/S4/AC2.
{"a read of the lease alone is no dependency, and the controller's own manifest is not judged",
map[string]string{"mod-l": `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`,
"mesh-controller": `{"module":"mesh-controller","version":"1","invokes":["seat:mesh-controller.status"]}`},
want{}},
// 774/S4/AC1, 774/S30/AC1: the keeper's read of the controller's conditions put back is refused in either phase,
// since the lease write's delivery dropped its entry (it was kept by ADR 0564 §9 before).
{"the keeper's read of the controller's conditions, put back, is refused",
map[string]string{"mesh-conditions": `{"module":"mesh-conditions","version":"1","reads":["mesh-controller.conditions","mesh-controller.lease"]}`},
want{deps: []string{"mesh-conditions read mesh-controller.conditions unkept"},
write: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write",
before: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write"}},
// 774/S30/AC1: the three kept to the write pass in either phase.
{"the artifact sweep's collect is kept to the write itself",
map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","version":"1","invokes":["seat:mesh-controller.collect"]}`},
want{deps: []string{"artifact-store-tools invoke seat:mesh-controller.collect kept by ADR 0571 §4, §13"}}},
{"the builder's consume of artifacts-gone is kept to the write itself",
map[string]string{"mesh-delivery-builder": `{"module":"mesh-delivery-builder","version":"1","consumes":["mesh-controller.artifacts-gone"]}`},
want{deps: []string{"mesh-delivery-builder consume mesh-controller.artifacts-gone kept by ADR 0571 §4, §13"}}},
{"a read no record keeps fails both phases, named",
map[string]string{"mod-u": `{"module":"mod-u","version":"1","reads":["mesh-controller.declared-packages"]}`},
want{deps: []string{"mod-u read mesh-controller.declared-packages unkept"},
write: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write",
before: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write"}},
{"a seat verb and a module tool of the controller's, a consume and a wait are each one; every tool (*) is none",
map[string]string{"mod-i": `{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` +
`"consumes":["mesh-controller.checked","mod-x.thing"],"uses":["mesh-controller"]}`,
"node-tools": `{"module":"node-tools","version":"1","waits":["mesh-controller.checked"]}`},
want{deps: []string{"mod-i consume mesh-controller.checked unkept", "mod-i invoke mesh-controller.status unkept",
"mod-i invoke seat:mesh-controller.root-free unkept", "mod-i uses mesh-controller unkept", "node-tools waits mesh-controller.checked unkept"},
write: "mod-i declares invoke seat:mesh-controller.root-free, which no record keeps until the lease write (ADR 0571 §13); ended by ADR 0571 §11",
before: "node-tools declares waits mesh-controller.checked"}},
{"a process's own invoke of the controller is its module's dependency, named by the process",
map[string]string{"mod-p": `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},{"id":"worker","type":"process","run":["./w"],"user":"mod-p",` +
`"bus":{"invokes":["seat:mesh-controller.held"]}}]}`},
want{deps: []string{"mod-p invoke seat:mesh-controller.held unkept"},
write: "mod-p (process worker) declares invoke seat:mesh-controller.held", before: "mod-p (process worker)"}},
{"no manifest fails both phases", map[string]string{}, want{write: "no manifest was read", before: "no manifest was read"}},
} {
manifests := map[string]Manifest{}
for name, raw := range row.manifests {
m, err := Decode([]byte(raw))
if err != nil {
t.Fatalf("%s: %s: %v", row.name, name, err)
}
manifests[name] = m
}
var deps []string
for _, d := range Dependencies(manifests) {
kept := "unkept"
if d.Kept != "" {
kept = "kept by " + d.Kept
}
deps = append(deps, d.Module+" "+d.Kind+" "+d.What+" "+kept)
}
if !slices.Equal(deps, row.want.deps) {
t.Errorf("%s: dependencies %q, want %q", row.name, deps, row.want.deps)
}
for phase, reason := range map[string]string{PhaseWrite: row.want.write, PhaseBefore: row.want.before} {
got := ManifestRefusals(manifests, phase)
if (reason == "") != (len(got) == 0) || !slices.ContainsFunc(got, func(r string) bool { return strings.Contains(r, reason) }) && reason != "" {
t.Errorf("%s: at %s refused %q, want %q", row.name, phase, got, reason)
}
}
}
}
+1
View File
@@ -9,6 +9,7 @@ import (
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit.
func TestTheDeliverySeatPromisesChecks(t *testing.T) {
t.Run("the seat mesh-grants promises the lease write", grantsSeatPromisesTheLeaseWrite)
seat, ok := SeatNamed(DeliverySeat)
if !ok {
t.Fatal("the delivery seat is not in the set")
+96
View File
@@ -0,0 +1,96 @@
package catalogue
// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by
// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs
// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token:
// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and
// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go).
// EnrolmentSeat is the seat mesh-enrolment.
const EnrolmentSeat = "mesh-enrolment"
// The seat's six verbs (ADR 0587 §1, §4-§8).
const (
EnrolmentVerbIssue = "issue"
EnrolmentVerbRedeem = "redeem"
EnrolmentVerbConfirm = "confirm"
EnrolmentVerbRevoke = "revoke"
EnrolmentVerbTokens = "tokens"
EnrolmentVerbAgain = "again"
)
// The seat's five events (ADR 0587 §1, §2, §5): said by its holder on the seat, each carrying an enrolment key's whole
// content except enrolment-expired, which names the node and the enrolment-issued event that carried it.
const (
EnrolmentIssuedEvent = "enrolment-issued"
NodeEnrolledEvent = "node-enrolled"
EnrolmentRefusedEvent = "enrolment-refused"
EnrolmentRevokedEvent = "enrolment-revoked"
EnrolmentExpiredEvent = "enrolment-expired"
)
// EnrolmentEvents are the seat's events, in the order an enrolment says them; the last four each end one.
var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, EnrolmentRefusedEvent, EnrolmentRevokedEvent,
EnrolmentExpiredEvent}
// enrolmentDecision is the record that made the seat.
const enrolmentDecision = "novox/hq ADR 0587"
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
// the seat is new with its holder, so no holder of before serves fewer.
func enrolmentVerbs() []Verb {
str := func(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
return []Verb{
{Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
"The token itself is read only through tokens.",
Input: schema(map[string]string{
"node": "the new machine's name in the mesh",
"why": "why it joins, shown to the operator with the ask",
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
// enters placement with it and the hub peers it before redeem.
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
}, []string{"node", "why", "overlay_key"})},
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
"it finishes what is not done and answers the same; with other keys it is refused.",
Input: map[string]any{"type": "object", "additionalProperties": false,
"required": []string{"node", "public_key", "sealing_key", "proof"},
"properties": map[string]any{
"node": str("the node the token was issued for"),
"public_key": str("the identity key's public half, base64"),
"overlay_key": str("the private network key's public half"),
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
"serving_key": str("the serving key's public half, which the certificate binds"),
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
}}},
{Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
"enrolment.",
Input: schema(map[string]string{}, nil)},
{Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
"it, records the act as a hand act and says enrolment-revoked.",
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
[]string{"node", "why"})},
{Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
"the bucket's fill against its cap, and whether issuing is on and why (the lease). For a node at live, its " +
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
nil, "history")},
{Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
"and nothing more.",
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
}
}
+3 -6
View File
@@ -744,12 +744,9 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
if strings.Contains(nft, "\t\ttcp dport 22 accept") {
t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft)
}
}
// And from outside as well, on a machine that faces outward — because that is the way in when the
// private network is the thing that broke.
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
// And from outside as well, on a machine that faces outward — because that is the way in when the
// private network is the thing that broke.
nft = AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
}
+53
View File
@@ -0,0 +1,53 @@
package catalogue
// **The lease write, a verb of the seat `mesh-grants`** (novox/hq ADR 0582 clause 1, issue 774): the grants part
// holds the seat once for the mesh and, on the operator's warrant, hands the controller's lease to itself and back.
// It serves two verbs and says three events, none carrying a secret:
//
// - lease-handed: the lease names mesh-grants, written on the operator's warrant (clause 3);
// - lease-altered: the lease key or its bucket's expiry differs from what mesh-grants wrote (clause 4);
// - removal-failed: the day-later removal of the controller's consumer or stream failed (clause 6).
// GrantsSeat is the seat the grants part (mesh-grants) holds.
const GrantsSeat = "mesh-grants"
// The seat's verbs.
const (
GrantsVerbLeaseWrite = "lease-write"
GrantsVerbLeaseHandBack = "lease-hand-back"
)
// The seat's events.
const (
GrantsEventLeaseHanded = "lease-handed"
GrantsEventLeaseAltered = "lease-altered"
GrantsEventRemovalFailed = "removal-failed"
)
// grantsDecision is the record that made the seat.
const grantsDecision = "novox/hq ADR 0582"
// grantsEvents are the seat's events, in the order the write meets them.
func grantsEvents() []string {
return []string{GrantsEventLeaseHanded, GrantsEventLeaseAltered, GrantsEventRemovalFailed}
}
// grantsVerbs are the seat's verbs (ADR 0582 clauses 1, 2, 3, 5).
func grantsVerbs() []Verb {
return []Verb{
{Name: GrantsVerbLeaseWrite, Description: "Hand the controller's lease to mesh-grants, kept for good. Refused, " +
"naming the check, until the lease check at phase write passes and every condition it does not judge reads " +
"green. Otherwise the operator is asked once; on the warrant the checks are read again, the controller's " +
"service is stopped, the bus's user list is composed without the controller's write on the lease, the lease's " +
"expiry is removed and the key holder names mesh-grants, said as lease-handed and recorded as a hand act. A no " +
"or an expiry of the ask acts on nothing.",
Input: schema(map[string]string{"why": "why the lease is handed now, shown to the operator and kept"},
[]string{"why"})},
{Name: GrantsVerbLeaseHandBack, Description: "Give the lease back to the controller, for a day after the write: " +
"asked and warranted the same way, it restores the lease's fifteen-second expiry, deletes the key holder, " +
"gives the controller its rights back once the list is loaded, and starts the controller. Refused once the " +
"day-later removal ran: the controller would start without its consumer and stream.",
Input: schema(map[string]string{"why": "why the lease goes back, shown to the operator and kept"},
[]string{"why"})},
}
}
+68
View File
@@ -0,0 +1,68 @@
package catalogue
import (
"reflect"
"strings"
"testing"
)
// The seat mesh-grants (novox/hq ADR 0582 clause 1, issue 774, 774/S1/AC2): its holder serves lease-write and
// lease-hand-back, both a condition of holding, and nothing else; the seat says lease-handed, lease-altered and
// removal-failed. A case of TestTheDeliverySeatPromisesChecks, the seat-verb test, within the test budget.
func grantsSeatPromisesTheLeaseWrite(t *testing.T) {
seat, ok := SeatNamed(GrantsSeat)
if !ok {
t.Fatal("the seat mesh-grants is not in the set")
}
if seat.Scope != ScopeMesh || seat.Delivers != "" {
t.Errorf("mesh-grants is at scope %q delivering %q, want mesh and nothing", seat.Scope, seat.Delivers)
}
if got, want := VerbNames(seat.Serves), []string{"lease-write", "lease-hand-back"}; !reflect.DeepEqual(got, want) {
t.Errorf("mesh-grants serves %v, want %v", got, want)
}
if want := []string{"lease-handed", "lease-altered", "removal-failed"}; !reflect.DeepEqual(seat.Emits, want) {
t.Errorf("mesh-grants says %v, want %v", seat.Emits, want)
}
for _, v := range seat.Serves {
if v.Optional {
t.Errorf("%s is optional: the holder ships with the seat, so every verb is a condition of holding", v.Name)
}
props, _ := v.Input["properties"].(map[string]any)
if req, _ := v.Input["required"].([]string); len(props) != 1 || !reflect.DeepEqual(req, []string{"why"}) {
t.Errorf("%s takes %v requiring %v, want only a why", v.Name, props, req)
}
}
claim := func(serves ...string) Manifest {
return Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeMesh, Serves: serves}}}
}
for _, c := range []struct {
name string
m Manifest
refuse string // empty: held
}{
{"both verbs", claim("lease-write", "lease-hand-back"), ""},
{"without the hand-back", claim("lease-write"), "lease-hand-back"},
{"a verb the seat does not promise", claim("lease-write", "lease-hand-back", "lease-take"), "lease-take"},
{"at node scope", Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeNode,
Serves: []string{"lease-write", "lease-hand-back"}}}}, "scope"},
} {
err := CanHold(c.m, seat)
switch {
case c.refuse == "" && err != nil:
t.Errorf("%s: refused: %v", c.name, err)
case c.refuse != "" && (err == nil || !strings.Contains(err.Error(), c.refuse)):
t.Errorf("%s: %v, want a refusal naming %q", c.name, err, c.refuse)
}
}
// No other seat changed: the set is the one before it with mesh-grants added beside mesh-hand-acts.
var names []string
for _, s := range DefaultSeats() {
names = append(names, s.Name)
}
at := strings.Index(strings.Join(names, " "), HandActsSeat+" "+GrantsSeat+" ")
if at < 0 {
t.Errorf("mesh-grants does not follow mesh-hand-acts in the seat set: %v", names)
}
}
+11 -2
View File
@@ -31,7 +31,10 @@ func placementVerbs() []Verb {
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
"layer, mesh-wide-values, values)",
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
"on the same warrant)",
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
return []Verb{
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
@@ -52,11 +55,17 @@ func placementVerbs() []Verb {
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
"caller, as the runtime proves it.",
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
"properties": map[string]any{
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
"changes": changes,
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
// Approve is the proposal's; the holder writes it with no ask of its own.
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
"warrant {node, address, ask, warrant}, naming the change's node and address",
"required": []string{"node", "address", "ask", "warrant"}},
}}},
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
"candidates are deleted and placement-withdrawn carries it whole. With why.",
+57 -6
View File
@@ -233,13 +233,59 @@ var defaultSeats = append([]Seat{
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
"kind node-certificate signs the certificate of the calling machine's internal name for the serving key " +
"its record states, built from bus state alone and asked by that machine's node-engine only; kind " +
"enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " +
"Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.",
"kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " +
"node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " +
"a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " +
"redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " +
"and the signer's fingerprint, and says signed; never a key.",
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
"node": "the machine, for a node certificate: the caller's own",
"reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})},
"node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols",
"serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented",
"reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})},
// Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's
// manifest names for these two.
{Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " +
"issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " +
"written as the bus user enrol.<node>. Answers only what the joining machine must trust (the bus's " +
"address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " +
"sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " +
"dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " +
"carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).",
Input: withObject(schema(map[string]string{"node": "the node the token is for",
"expires": "when the token expires (RFC 3339), as the enrolment's key says",
"sealed": "\"true\": answer the token sealed to the operator key",
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
"address": "the joining node's tunnel address",
"key": "the joining machine's own tunnel public key, as given at issue",
"range": "the private network's range, a prefix"})},
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
"node's node-logins key naming node.<node>. Asked by mesh-enrolment alone. Never the value in the clear.",
Input: schema(map[string]string{"node": "the joining node",
"sealing_key": "the sealing key's public half the node presented, which the account is sealed to",
"keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)",
"retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")},
// A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus
// node last; the state node-logins says where each node stands.
{Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " +
"answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " +
"its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " +
"rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " +
"node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.",
Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence",
"act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})},
{Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " +
"asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " +
"The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " +
"without it.",
Input: schema(map[string]string{}, nil)},
{Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " +
"the new login and its value dropped, the old one kept; a node that switched switches back first.",
Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})},
}},
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
@@ -302,6 +348,11 @@ var defaultSeats = append([]Seat{
// policy and the bus step at the assigner, and the hand-act log.
{Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
{Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
// The lease write (novox/hq ADR 0582 clause 1, grants_seat.go): the grants part hands the controller's lease to
// itself and back on the operator's warrant.
{Name: GrantsSeat, Scope: ScopeMesh, Decision: grantsDecision, Emits: grantsEvents(), Serves: grantsVerbs()},
// Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment.
{Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()},
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
Serves: artifactSweepVerbs()},
+22 -2
View File
@@ -1,6 +1,9 @@
package catalogue
import "testing"
import (
"slices"
"testing"
)
// The vault's provision is one the controller itself dereferences — every minted credential is
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
@@ -65,6 +68,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
"rotate": {"node", "module", "name", "provider", "provision", "by"},
// 774/S37/AC1: the enrolment verbs mesh-enrolment asks (ADR 0587 §3, §5).
"enrolment-token": {"node", "expires", "sealed", "drop", "tunnel"},
"node-account": {"node", "sealing_key", "keys_digest", "retire"},
"sign": {"kind", "node", "serving_key", "reply"},
// 774/S38: the rotation verbs (ADR 0583 §2, §3, §5).
"rotate-node": {"node", "why", "act"},
"node-account-confirm": {},
"abandon": {"node"},
}
for name, args := range want {
var verb *Verb
@@ -89,6 +100,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
if props["value"] != nil {
t.Errorf("%s takes a value in the clear", name)
}
// The first tunnel the token carries (ADR 0587 §3, insight of 2026-11-02): an object of the hub's key, its endpoint
// and the node's address.
if name == "enrolment-token" {
tunnel, _ := props["tunnel"].(map[string]any)
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key", "key", "range"}) {
t.Errorf("enrolment-token's tunnel is %v", tunnel)
}
}
}
// The holders `issued` names (issue 749) are a list of {node, module}.
for _, verb := range seat.Serves {
@@ -105,7 +124,8 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
}
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
"rotate-node", "node-account-confirm", "abandon"}}}}
if err := CanHold(serving, seat); err != nil {
t.Fatalf("the vault serving its verbs was refused: %v", err)
}
+10
View File
@@ -4,6 +4,8 @@ import (
"bytes"
"encoding/json"
"fmt"
"maps"
"slices"
)
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
@@ -596,6 +598,14 @@ var ControllerVerbs = []Verb{
//
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
// name is refused when the verb is called, never passed over.
// withObject adds to a verb's input s the property name, an object of the string fields given, each required.
func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any {
inner := schema(fields, slices.Sorted(maps.Keys(fields)))
inner["description"], inner["additionalProperties"] = description, false
s["properties"].(map[string]any)[name] = inner
return s
}
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
isSwitch := map[string]bool{}
for _, s := range switches {
+25
View File
@@ -1,6 +1,7 @@
package catalogue
import (
"slices"
"strings"
"testing"
)
@@ -25,6 +26,30 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) {
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
// 774/S34/AC1: the seat mesh-enrolment is held by a module serving its six verbs, and a seventh is refused.
enrolment, ok := SeatNamed(EnrolmentSeat)
if !ok || enrolment.Scope != ScopeMesh || len(enrolment.Emits) != 5 {
t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment)
}
six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}
// The tunnel key is given at issue (the operator, 2026-11-02 01:05).
if issue := enrolment.Serves[0]; issue.Name != "issue" || !slices.Contains(issue.Input["required"].([]string), "overlay_key") {
t.Errorf("issue requires %v", issue.Input["required"])
}
holder := func(serves ...string) Manifest {
return Manifest{Module: "mesh-enrolment", Tools: serves,
Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}}
}
if err := CanHold(holder(six...), enrolment); err != nil {
t.Errorf("the holder serving the six verbs was refused: %v", err)
}
if err := CanHold(holder(six[:5]...), enrolment); err == nil || !strings.Contains(err.Error(), "does not serve again") {
t.Errorf("a holder missing again: %v", err)
}
if err := CanHold(holder(append(six, "mint")...), enrolment); err == nil || !strings.Contains(err.Error(), "mint") {
t.Errorf("a holder saying it serves mint: %v", err)
}
was := Seats()
t.Cleanup(func() { UseSeats(was) })
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
+110 -39
View File
@@ -223,9 +223,56 @@ func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest,
// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the
// read: that is the bus's, not the module's.
func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
b, err := readBase(ctx, r, mesh, s)
b.refuseOwn(refuse)
if err != nil {
return compose.Input{}, err
}
return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
}
// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once
// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice).
type base struct {
in compose.Input
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
modules map[string]delivery.Module
// ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
// read: every scope holds it.
ownRefused [][2]string
}
// refuseOwn hands refuse each module placed on the composing node whose manifest is refused.
func (b base) refuseOwn(refuse func(module string, err error)) {
if refuse == nil {
return
}
for _, r := range b.ownRefused {
refuse(r[0], errors.New(r[1]))
}
}
// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over
// one base never see each other's builds.
func (b base) cloned() compose.Input {
in := b.in
in.Catalogue = maps.Clone(b.in.Catalogue)
in.Nodes = slices.Clone(b.in.Nodes)
for i := range in.Nodes {
in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept)
}
in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes)
in.OwnSecrets = slices.Clone(b.in.OwnSecrets)
return in
}
// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values.
func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
var b base
placements, err := r.Placements(ctx)
if err != nil {
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
return b, fmt.Errorf("frombus: placement: %w", err)
}
if len(s.Placements) > 0 {
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
@@ -238,7 +285,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
continue
}
if p.Node != node {
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
}
given[node] = *p
}
@@ -246,14 +293,14 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
}
records, err := r.Records(ctx)
if err != nil {
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
return b, fmt.Errorf("frombus: node records: %w", err)
}
modules, err := r.Modules(ctx)
if err != nil {
return compose.Input{}, fmt.Errorf("frombus: the indexer's modules: %w", err)
return b, fmt.Errorf("frombus: the indexer's modules: %w", err)
}
if _, ok := placements[s.Node]; !ok && !s.Every {
return compose.Input{}, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
}
in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
@@ -279,18 +326,6 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
}
}
parse := parser(mesh)
read := func(module, digest string) (catalogue.Manifest, error) {
raw, err := r.Manifest(ctx, digest)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
}
man, err := parse(raw)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
}
return man, nil
}
named := func(field *map[string]string, module, why string) {
if *field == nil {
*field = map[string]string{}
@@ -309,7 +344,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
raw, err := r.Manifest(ctx, m.Manifest)
if err != nil {
// Not read is no refusal of the module: a bus that does not answer is waited out by the caller.
return compose.Input{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
}
// One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read
// follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused
@@ -318,8 +353,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
man, why := taken(name, raw, m)
if why != "" {
named(&in.Refused, name, why)
if refuse != nil && ownPlaced[name] {
refuse(name, errors.New(why))
if ownPlaced[name] {
b.ownRefused = append(b.ownRefused, [2]string{name, why})
}
} else if aside := man.SetAside(); aside != "" {
named(&in.SetAside, name, aside)
@@ -332,11 +367,11 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
for _, name := range delivery.ProvidedModules() {
raw, err := json.Marshal(overlay.Manifest())
if err != nil {
return compose.Input{}, err
return b, err
}
var private catalogue.Manifest
if err := json.Unmarshal(raw, &private); err != nil {
return compose.Input{}, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
}
in.Catalogue[name] = private
}
@@ -357,13 +392,13 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
var values map[string]any
if withValues {
if values, err = r.Values(ctx, node, m.Module); err != nil {
return compose.Input{}, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
}
} else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 {
// What a provider elsewhere serves from its settings is told to every consumer: the composing node reads
// those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks).
if values, err = r.Served(ctx, node, m.Module, keys); err != nil {
return compose.Input{}, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
strings.Join(keys, ", "), err)
}
}
@@ -436,14 +471,35 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records)
}
if len(missing) > 0 {
return compose.Input{}, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
strings.Join(missing, ", "))
}
b.in, b.placements, b.records, b.modules = in, placements, records, modules
return b, nil
}
// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets.
// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once.
func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
var err error
parse := parser(mesh)
read := func(module, digest string) (catalogue.Manifest, error) {
raw, err := r.Manifest(ctx, digest)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
}
man, err := parse(raw)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
}
return man, nil
}
if !s.Every {
if _, ok := records[s.Node]; !ok {
if _, ok := b.records[s.Node]; !ok {
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
}
if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse, wanted); err != nil {
if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
return compose.Input{}, err
}
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
@@ -603,15 +659,8 @@ func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own.
func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) {
resolved, err := compose.Resolve(in, node)
if err != nil {
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
}
rendered, err := compose.Render(in, node, mode)
if err != nil {
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
}
gens, err := compose.Generators(in)
// One composition for the three, so each node of the mesh is resolved once (issue 1954).
resolved, rendered, gens, err := compose.Composing(in, node, mode)
if err != nil {
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
}
@@ -673,7 +722,8 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
// (issue 1126).
func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
refuse func(module string, err error), wanted map[string]string) error {
// stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
@@ -716,6 +766,24 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
if b.Manifest == "" {
return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp)
}
// A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest.
keeper, keeps := r.(takenInKeeper)
if keeps && !addressed {
address, addressErr = compose.ArtifactStoreAddress(*in, s.Node)
addressed = true
}
// Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did.
key, keyed := takenInKey(module, fp, address, *b)
keyed = keyed && addressErr == nil && address != ""
if keeps && keyed {
if raw, ok := keeper.takenIn(key); ok {
resolved, err := parse(raw)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
}
return resolved, nil
}
}
src, err := read(module, b.Manifest)
if err != nil {
return catalogue.Manifest{}, err
@@ -733,6 +801,9 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
if resolved, err = parse(raw); err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
}
if keeps && keyed {
keeper.keepTakenIn(key, raw)
}
return resolved, nil
}
// A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one
@@ -766,7 +837,7 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) {
continue // nothing is built of it: its manifest is what it composes from
}
b, err := r.Build(ctx, pm.Module, m.FP2)
b, err := build(ctx, pm.Module, m.FP2)
if err != nil {
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err)
if refuse == nil {
@@ -801,7 +872,7 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
}
for _, module := range sortedKeys(keptAt) {
fp := keptAt[module]
b, err := r.Build(ctx, module, fp)
b, err := build(ctx, module, fp)
switch {
case err != nil:
err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err)
@@ -2,10 +2,14 @@ package frombustest
import (
"context"
"reflect"
"strings"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/compose/fromruntime"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -115,6 +119,18 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
}
// Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
// and where the time went is said by phase.
keptAt := FPs()
at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
!reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
}
if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
t.Errorf("%s: the phases are said as %q", row.name, said)
}
if row.bad == "" || row.elsewhere {
if len(held) > 0 {
t.Errorf("%s: held %v", row.name, held)
@@ -135,6 +151,115 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
}
}
// ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is
// asked, and a manifest once per digest.
byKey := map[string]int{}
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
byKey[state+" watched"]++
return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
}, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
w := fromruntime.Watched{WatchedStates: ws}
for range 2 {
_, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
_, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found {
t.Error("the watched reader does not answer the placement from its copy")
}
}
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 ||
byKey[frombus.PlacementRead+" watched"] != 1 {
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey)
}
fetched := 0
keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
for range 2 {
if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" {
t.Fatalf("the kept fetch answered %q, %v", body, err)
}
}
if fetched != 1 {
t.Errorf("a templates archive was fetched %d times, want once", fetched)
}
// Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
// and the plans are the same.
b, err := Provisioned()
if err != nil {
t.Fatal(err)
}
reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
fetched++
_, digest, _ := strings.Cut(url, "/blobs/")
return b.Archives[digest], nil
}), fetched
var first map[string]catalogue.Plan
for i := range 2 {
plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
t.Fatalf("composed %v (held %v): %v", plans, held, err)
}
first = plans
}
if fetched-archives != 1 {
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
}
}
// statesOf is the bus b as a module's read states hold it.
func statesOf(t *testing.T, b *Bus) frombus.States {
t.Helper()
s := states{}
put := func(state, key string, raw []byte, err error) {
if err != nil {
t.Fatal(err)
}
if s[state] == nil {
s[state] = map[string][]byte{}
}
s[state][key] = raw
}
for node, p := range b.Placement {
raw, err := delivery.EncodePlacement(p)
put(frombus.PlacementRead, "placement."+node, raw, err)
}
for node, rec := range b.Record {
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
put(frombus.HaveRead, node+".node", raw, err)
}
for module, m := range b.Module {
key, _ := delivery.ModulesKey(module)
raw, err := delivery.EncodeModule(m)
put(frombus.ModulesRead, key, raw, err)
}
for digest, man := range b.Manifests {
key, _ := delivery.ManifestsKey(digest)
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
put(frombus.ManifestsRead, key, raw, err)
}
for at, build := range b.Builds {
module, fp, _ := strings.Cut(at, " ")
key, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
put(frombus.BuildsRead, key, raw, err)
}
return s
}
// states are read states in memory.
type states map[string]map[string][]byte
func (s states) Get(state, key string) ([]byte, bool, error) {
v, ok := s[state][key]
return v, ok, nil
}
func (s states) Keys(state string) ([]string, error) {
var keys []string
for k := range s[state] {
keys = append(keys, k)
}
return keys, nil
}
func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
+10 -1
View File
@@ -43,13 +43,22 @@ type States interface {
type Fetch func(ctx context.Context, url string) ([]byte, error)
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
func StatesReader(s States, fetch Fetch) Reader { return statesReader{s: s, fetch: fetch} }
// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again
// takes in only a build it has not seen.
func StatesReader(s States, fetch Fetch) Reader {
return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}}
}
type statesReader struct {
s States
fetch Fetch
kept *keptTakeIns
}
func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) }
func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) }
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
keys, err := r.s.Keys(read)
if err != nil {
+147
View File
@@ -0,0 +1,147 @@
package frombus
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
type HeldPlans struct {
Plans map[string]catalogue.Plan
Held map[string]string
Err error
}
// Phase is one step of a composition and how long it took.
type Phase struct {
Name string `json:"phase"`
Took time.Duration `json:"took"`
}
// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one
// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err.
// took names each phase in order: the read, then each scope's builds and its plans.
func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) {
start := time.Now()
mark := func(name string) {
now := time.Now()
took = append(took, Phase{Name: name, Took: now.Sub(start)})
start = now
}
b, err := readBase(ctx, r, mesh, s)
mark("read")
builds := onceBuilds(r)
newestScope := s
newestScope.KeptAt = nil
for _, sc := range []struct {
scope Scope
into *HeldPlans
name string
}{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} {
held := map[string]string{}
sc.into.Held = held
refuse := func(module string, err error) { held[module] = err.Error() }
b.refuseOwn(refuse)
if err != nil {
sc.into.Err = err
continue
}
in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil)
mark("builds at " + sc.name)
if err != nil {
sc.into.Err = err
continue
}
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
mark("plans at " + sc.name)
}
return at, newest, took
}
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
// builds of the same moment.
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
type answer struct {
b *delivery.BuilderBuild
err error
}
read := map[string]answer{}
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
key := module + " " + fp
if a, ok := read[key]; ok {
return a.b, a.err
}
b, err := r.Build(ctx, module, fp)
read[key] = answer{b, err}
return b, err
}
}
// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes,
// by the content it was made from, so a composition made again on every change takes in only a build it has not seen.
type takenInKeeper interface {
takenIn(key string) ([]byte, bool)
keepTakenIn(key string, raw []byte)
}
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
raw, err := json.Marshal(b)
if err != nil {
return "", false
}
sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...))
return hex.EncodeToString(sum[:]), true
}
// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again.
const takenInAtMost = 512
// keptTakeIns is a reader's kept take-ins.
type keptTakeIns struct {
mu sync.Mutex
kept map[string][]byte
}
func (k *keptTakeIns) takenIn(key string) ([]byte, bool) {
k.mu.Lock()
defer k.mu.Unlock()
raw, ok := k.kept[key]
return raw, ok
}
func (k *keptTakeIns) keepTakenIn(key string, raw []byte) {
k.mu.Lock()
defer k.mu.Unlock()
if k.kept == nil || len(k.kept) >= takenInAtMost {
k.kept = map[string][]byte{}
}
k.kept[key] = raw
}
// PhasesSaid says the phases in one line, each in milliseconds.
func PhasesSaid(took []Phase) string {
out := ""
for i, p := range took {
if i > 0 {
out += ", "
}
out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds())
}
return out
}
+53 -1
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"sync"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
@@ -28,9 +29,60 @@ func (States) Get(state, key string) ([]byte, bool, error) {
// Keys are a state's keys.
func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
// Reader is frombus's Reader over this bundle's read states, fetching archives over HTTP.
// Reader is frombus's Reader over this bundle's read states key by key, fetching archives over HTTP. A holder that
// composes again and again reads through Watched instead (novox/hq ADR 0580).
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole
// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
type Watched struct{ *stdio.WatchedStates }
// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy.
func (w Watched) Get(state, key string) ([]byte, bool, error) {
switch state {
case frombus.BuildsRead:
return w.Direct(state, key)
case frombus.ManifestsRead:
return w.Once(state, key)
}
return w.WatchedStates.Get(state, key)
}
// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition
// made again on every change reads it again otherwise.
func WatchedReader(w *stdio.WatchedStates) frombus.Reader {
return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch))
}
// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again.
const archivesAtMost = 64
// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it
// (frombus checks the digest on every read). A failed fetch is not kept.
func KeptFetch(fetch frombus.Fetch) frombus.Fetch {
var mu sync.Mutex
kept := map[string][]byte{}
return func(ctx context.Context, url string) ([]byte, error) {
mu.Lock()
body, ok := kept[url]
mu.Unlock()
if ok {
return body, nil
}
body, err := fetch(ctx, url)
if err != nil {
return nil, err
}
mu.Lock()
if len(kept) >= archivesAtMost {
clear(kept)
}
kept[url] = body
mu.Unlock()
return body, nil
}
}
// HTTPFetch reads an archive over HTTP, at most 256 MiB.
func HTTPFetch(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+25
View File
@@ -27,6 +27,23 @@ type composer struct {
// unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
// composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
unhostable []string
// What one composition looks at again and again, each worked out once over its Input, which it never changes (issue
// 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is
// on the private network, and the rest of the mesh as each node sees it.
onNetwork []Node
onNetworkSet bool
rest map[string]restOf
plans map[string]planOf
}
// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it.
type restOf struct {
world catalogue.World
passes Passes
left []LeftOut
unhostable []string
err error
}
func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
@@ -249,6 +266,14 @@ func (c *composer) whereEveryoneIs() (map[string]string, error) {
// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not
// counted, and said.
func (c *composer) onTheNetwork() []Node {
if !c.onNetworkSet {
c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true
}
return slices.Clone(c.onNetwork)
}
// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved.
func (c *composer) onTheNetworkNow() []Node {
var out []Node
for _, n := range c.in.sorted() {
if n.Place.Address == "" {
+20
View File
@@ -35,6 +35,26 @@ func Render(in Input, node string, mode Mode) (Rendered, error) {
return newComposer(in).render(node, mode)
}
// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh
// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each
// answers as it does alone; the first refusal ends it, in that order, with what follows it empty.
func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) {
c := newComposer(in)
resolved, err := c.planFor(node)
if err != nil {
return Resolved{}, Rendered{}, nil, err
}
rendered, err := c.render(node, mode)
if err != nil {
return resolved, Rendered{}, nil, err
}
gens, err := c.generators()
if err != nil {
return resolved, rendered, nil, err
}
return resolved, rendered, gens, nil
}
func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
resolved, err := c.planFor(nodeName)
if err != nil {
+39 -1
View File
@@ -36,8 +36,46 @@ func Resolve(in Input, node string) (Resolved, error) {
// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer.
func IsUnresolvable(err error) bool { return unresolvable(err) }
// planFor is the controller's planFor.
// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its
// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read.
func (c *composer) planFor(nodeName string) (Resolved, error) {
p, ok := c.plans[nodeName]
if !ok {
p.resolved, p.err = c.planForNow(nodeName)
if c.plans == nil {
c.plans = map[string]planOf{}
}
c.plans[nodeName] = p
}
if p.err != nil {
return Resolved{}, p.err
}
return p.resolved.copied(), nil
}
// planOf is planFor's answer for one node, kept.
type planOf struct {
resolved Resolved
err error
}
// copied is r with its own needs, words and settings lists: what a caller may change of it.
func (r Resolved) copied() Resolved {
out := r
out.Plan.Needs = slices.Clone(r.Plan.Needs)
out.Foreseen = slices.Clone(r.Foreseen)
out.NotGiven = slices.Clone(r.NotGiven)
if r.Settings != nil {
out.Settings = make(catalogue.SettingsBy, len(r.Settings))
for m, layers := range r.Settings {
out.Settings[m] = slices.Clone(layers)
}
}
return out
}
// planForNow is planFor worked out.
func (c *composer) planForNow(nodeName string) (Resolved, error) {
resolved, err := c.resolution(nodeName)
if err != nil {
return Resolved{}, err
+17
View File
@@ -3,6 +3,7 @@ package compose
import (
"errors"
"fmt"
"slices"
"sort"
"strings"
@@ -59,6 +60,22 @@ func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []Left
}
func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) {
r, ok := c.rest[exclude]
if !ok {
r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude)
r.unhostable = c.unhostable
if c.rest == nil {
c.rest = map[string]restOf{}
}
c.rest[exclude] = r
}
// Each caller has its own copy of what it may set or add to; the maps and the slices within are only read.
c.unhostable = slices.Clone(r.unhostable)
return r.world, r.passes, slices.Clone(r.left), r.err
}
// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice.
func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) {
shelf := c.in.Catalogue
holdings := append([]catalogue.Held(nil), c.in.Holdings...)
nodes := c.in.sorted()
+4 -4
View File
@@ -91,8 +91,8 @@ func TestToolchainGate(t *testing.T) {
gate, set, said := start(t, true)
key, value := gateReport(t, "ace", "v26.12.0", old)
gate.Toolchain(NodeBuildModule, key, value, false)
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree") {
t.Fatalf("before the placement is settled every report counts; got %q", none)
if a := delivery.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out
t.Fatalf("before the placement is settled every report counts; got %+v", a)
}
k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter
gate.Placement(k, v, false, old)
@@ -111,8 +111,8 @@ func TestToolchainGate(t *testing.T) {
// Placed on ace with a report written since: it counts, and the builders really disagree.
k, v = placing(t, "ace", GoBuildModule, NodeBuildModule)
gate.Placement(k, v, false, old.Add(-time.Minute))
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree: v26.12.0 recipe f59571cf4be75adf on ace") {
t.Fatalf("builders that run different versions still disagree; got %q", none)
if a := delivery.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" {
t.Fatalf("builders that run different versions: the majority is agreed and ace sits out; got %+v", a)
}
// Taken off ace again (its key deleted): its report goes again.
if !gate.Placement(k, nil, true, placed) {
+42
View File
@@ -40,6 +40,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
{"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` +
`"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` +
`"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
// A node fallen back to its old login (ADR 0583 §6), which the watchman raises as node-account-fallback.
{"a node's health report, fallen back to its old login", `{"v":1,"node":"anchor","fallback":{"node":"anchor",` +
`"from":"node.anchor--b","why":"the new login's test-dial was refused","at":"2026-11-02T01:00:00Z"},` +
`"at":"2026-11-02T01:00:00Z"}`, &NodeHealth{}},
{"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}},
} {
if err := json.Unmarshal([]byte(c.raw), c.into); err != nil {
@@ -60,6 +64,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
t.Error("a report naming no node was encoded")
}
if _, err := EncodeNodeHealth(NodeHealth{Node: "anchor", Fallback: &LoginFallback{Node: "ace", From: "node.anchor--b",
Why: "refused", At: time.Now()}}); err == nil {
t.Error("a fall back naming another node was encoded")
}
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
held := SeatHoldersOf(map[string]Placement{
"ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
@@ -142,6 +150,40 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
t.Errorf("%s keyed %q, %v", pull, got, err)
}
}
// ADR 0565 §6: the engine works out its node's key of `packages` from the compositions it composed, the mirror's bytes
// for the same resources (mesh-controller internal/inventory/declared_packages_test.go aNodesPackages at 0fbb5ef3):
// a module's own composition, the node's own with a folded module's resource named by its id alone, and a file.
res := func(s ...string) []json.RawMessage {
var out []json.RawMessage
for _, r := range s {
out = append(out, json.RawMessage(r))
}
return out
}
comps := []Composed{
{Node: "one", Module: "web", Resources: res(`{"id":"pkg-curl","type":"package","package":"curl"}`,
`{"id":"pkg-nano","type":"package","package":"nano","absent":true}`, `{"id":"conf","type":"file","path":"/etc/web.conf"}`)},
{Node: "one", Resources: res(`{"id":"backup.pkg-curl","type":"package","package":"curl"}`)},
}
want := `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` +
`{"package":"nano","modules":["web"],"absent":true}]}`
got, err := DeclaredPackagesOf("one", comps)
if raw, _ := json.Marshal(got); err != nil || string(raw) != want {
t.Errorf("the node's packages are %s (%v)", raw, err)
}
if none, _ := DeclaredPackagesOf("one", nil); string(jsonOf(none)) != `{"node":"one","packages":[]}` || EnginePackagesRead != "mesh-host.packages" {
t.Errorf("a node declaring no package is %s", jsonOf(none))
}
// Their difference with the mirror: a package only one side holds, and one whose modules differ; none when they agree.
mirror := DeclaredPackages{Node: "one", Packages: []PackageDeclared{{Package: "curl", Modules: []string{"web"}},
{Package: "nano", Modules: []string{"web"}, Absent: true}, {Package: "vim", Modules: []string{"edit"}}}}
if d := DiffDeclaredPackages(got, mirror); len(d) != 2 || d[0].Package != "curl" || strings.Join(d[0].Engine, ",") != "backup,web" ||
d[1].Package != "vim" || len(d[1].Engine) != 0 || d[1].Mirror[0] != "edit" {
t.Errorf("the difference is %+v", d)
}
if d := DiffDeclaredPackages(got, got); d == nil || len(d) != 0 {
t.Errorf("one declaration differs from itself: %+v", d)
}
}
func jsonOf(v any) []byte {
+14 -1
View File
@@ -242,9 +242,22 @@ func TestRoundTrip(t *testing.T) {
t.Fatal(err)
}
back, err := DecodeGrantsUserList(raw)
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants || back.LeaseHanded {
t.Fatalf("decoded %+v, %v", back, err)
}
// ADR 0582 clause 3 (issue 774, 774/S3): a key written before the flag existed reads not handed; one written with
// it reads handed.
if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` +
`"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z"}`)); err != nil || old.LeaseHanded {
t.Errorf("a key from before the lease write decodes %+v, %v", old, err)
}
g.LeaseHanded = true
if raw, err := EncodeGrantsUserList(g); err != nil || !bytes.Contains(raw, []byte(`"lease-handed":true`)) {
t.Errorf("the handed flag is written as %s, %v", raw, err)
} else if back, err := DecodeGrantsUserList(raw); err != nil || !back.LeaseHanded {
t.Errorf("the handed flag reads back %+v, %v", back, err)
}
g.LeaseHanded = false
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object},
{Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} {
if _, err := EncodeGrantsUserList(bad); err == nil {
+137
View File
@@ -0,0 +1,137 @@
package delivery
import (
"encoding/json"
"fmt"
"slices"
"sort"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// **A node's declared packages, worked out by its engine** (novox/hq ADR 0565 §1, §6; issue 1953): the derivation and
// the comparison of the node-engine's state `packages` (EnginePackagesState, layout DeclaredPackages), so the engine
// that writes the key, the node seat's verb that compares it with the controller's mirror, and any reader work them out
// one way. The controller's own copy of the derivation (mesh-controller internal/inventory DeclaredPackagesValue) is
// not changed (ADR 0547 §1) and ends with its lease.
// EnginePackagesRead is the engine's state `packages` as a reader names it in `reads`: the node-engine's manifest is
// mesh-host's, so its bucket is mesh-host_packages, read as disk-load reads mesh-host.data (EngineDataRead).
const EnginePackagesRead = "mesh-host." + EnginePackagesState
// DeclaredPackagesOf is a node's key of `packages` from the compositions its engine composed for the node's wants:
// each resource of type package, by its name and whether it is declared absent, with the modules that declare it so,
// in the mirror's layout and order (names sorted, absent after present, modules sorted). A resource's module is the
// one it names, else its composition's, else the guess from its id (declaration.ModuleGuess); a package of no module
// lists none.
func DeclaredPackagesOf(node string, compositions []Composed) (DeclaredPackages, error) {
by := map[packageKey]map[string]bool{}
for _, c := range compositions {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
Type string `json:"type"`
Package string `json:"package"`
Absent bool `json:"absent"`
Module string `json:"module"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return DeclaredPackages{}, fmt.Errorf("delivery: a resource of %s's composition %q is not readable: %w", node, c.Module, err)
}
if r.Type != "package" || r.Package == "" {
continue
}
k := packageKey{r.Package, r.Absent}
if by[k] == nil {
by[k] = map[string]bool{}
}
module := r.Module
if module == "" {
module = c.Module
}
if module == "" {
module, _ = declaration.ModuleGuess(r.ID)
}
if module != "" {
by[k][module] = true
}
}
}
v := DeclaredPackages{Node: node, Packages: []PackageDeclared{}}
for _, k := range sortedPackageKeys(by) {
modules := make([]string, 0, len(by[k]))
for m := range by[k] {
modules = append(modules, m)
}
sort.Strings(modules)
v.Packages = append(v.Packages, PackageDeclared{Package: k.name, Modules: modules, Absent: k.absent})
}
return v, nil
}
// PackageDifference is one package two declarations of a node hold differently: the modules each side declares it
// with (present, or absent when Absent), none on a side that does not hold it.
type PackageDifference struct {
Package string `json:"package"`
Absent bool `json:"absent"`
Engine []string `json:"engine"`
Mirror []string `json:"mirror"`
}
// DiffDeclaredPackages is every package the engine's key and the controller's mirror hold differently for one node, in
// the layout's order: what the node seat's verb packages answers until the lease write (ADR 0565 §6). Empty when they
// agree.
func DiffDeclaredPackages(engine, mirror DeclaredPackages) []PackageDifference {
index := func(d DeclaredPackages) map[packageKey][]string {
out := map[packageKey][]string{}
for _, p := range d.Packages {
out[packageKey{p.Package, p.Absent}] = append([]string{}, p.Modules...)
}
return out
}
e, m := index(engine), index(mirror)
union := map[packageKey]bool{}
for k := range e {
union[k] = true
}
for k := range m {
union[k] = true
}
out := []PackageDifference{}
for _, k := range sortedPackageKeys(union) {
em, inE := e[k]
mm, inM := m[k]
if inE && inM && slices.Equal(em, mm) {
continue
}
if em == nil {
em = []string{}
}
if mm == nil {
mm = []string{}
}
out = append(out, PackageDifference{Package: k.name, Absent: k.absent, Engine: em, Mirror: mm})
}
return out
}
// packageKey is a package as the layout tells it apart: its name, and whether it is declared absent.
type packageKey struct {
name string
absent bool
}
// sortedPackageKeys is the keys of m by name, absent after present.
func sortedPackageKeys[V any](m map[packageKey]V) []packageKey {
keys := make([]packageKey, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].name != keys[j].name {
return keys[i].name < keys[j].name
}
return !keys[i].absent && keys[j].absent
})
return keys
}
+332
View File
@@ -0,0 +1,332 @@
package delivery
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/netip"
"strconv"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding
// no secret, with two kinds of key. `node.<node>`, one per open enrolment, holds an Enrolment; `address.<address>`
// reserves one private-network address for one open enrolment and holds the node's name and, from the ask, the ask and the issue's time. Every write is a
// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the
// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part,
// which composes the user `enrol.<node>` from a key at live and the vault's hash (§4). These constants are the seat's
// contract: every reader takes the names from here.
const (
// EnrolmentModule is the module holding the seat mesh-enrolment.
EnrolmentModule = catalogue.EnrolmentSeat
// EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares.
EnrolmentsState = "enrolments"
EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState
EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState
// The stages of an open enrolment (§2, §4, §5).
EnrolmentAsked = "asked"
EnrolmentLive = "live"
EnrolmentRedeeming = "redeeming"
// DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4).
DefaultTokenLife = time.Hour
MostTokenLife = 24 * time.Hour
// EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.<node>` (§4).
EnrolmentUserPrefix = "enrol."
enrolmentNodePrefix = "node."
enrolmentAddressPrefix = "address."
)
// Enrolment is one key `node.<node>` of `enrolments`: the node's name, the private-network address chosen at issue, the
// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and
// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token.
type Enrolment struct {
V int `json:"v"`
Node string `json:"node"`
Address string `json:"address"`
Why string `json:"why"`
Caller string `json:"caller"`
Ask string `json:"ask,omitempty"`
Stage string `json:"stage"`
Issued time.Time `json:"issued"`
Expires time.Time `json:"expires"`
KeysDigest string `json:"keys-digest,omitempty"`
// OverlayKey is the machine's tunnel public key, given at issue (the operator's decision of 2026-11-02 01:05),
// base64 of 32 bytes: at every stage, since the node enters placement with it on the operator's yes.
OverlayKey string `json:"overlay-key"`
}
// EnrolmentAddress is one key `address.<address>`: the node the address is reserved for and, from when the issue's ask
// is raised, that ask's id and the issue's time (ADR 0589 §6), so the reservation names the enrolment it holds the
// address for. The key is written when the address is reserved, before the ask: Ask and Issued are then both empty, and
// from the ask both set; one without the other is refused.
type EnrolmentAddress struct {
V int `json:"v"`
Node string `json:"node"`
Ask string `json:"ask,omitempty"`
Issued time.Time `json:"issued,omitzero"`
}
func (a EnrolmentAddress) check() error {
if err := checkName("node", a.Node); err != nil {
return err
}
if (strings.TrimSpace(a.Ask) == "") != a.Issued.IsZero() {
return fmt.Errorf("delivery: the address reserved for %s names its ask without the issue's time, or the reverse", a.Node)
}
return nil
}
// EnrolmentIssuedID is the id of the enrolment-issued event of node's enrolment issued at issued:
// `enrolment-issued.<node>.<issue time in Unix nanoseconds>`, what enrolment-expired names (EnrolmentExpiry).
func EnrolmentIssuedID(node string, issued time.Time) string {
return catalogue.EnrolmentIssuedEvent + "." + node + "." + strconv.FormatInt(issued.UnixNano(), 10)
}
// EnrolmentNodeKey is a node's key in `enrolments`.
func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node }
// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens).
func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address }
// EnrolmentKeyNode is the node a key `node.<node>` names, and whether it is one.
func EnrolmentKeyNode(key string) (string, bool) {
node, ok := strings.CutPrefix(key, enrolmentNodePrefix)
return node, ok && checkName("node", node) == nil
}
// EnrolmentKeyAddress is the address a key `address.<address>` names, and whether it is one.
func EnrolmentKeyAddress(key string) (string, bool) {
address, ok := strings.CutPrefix(key, enrolmentAddressPrefix)
if !ok {
return "", false
}
_, err := netip.ParseAddr(address)
return address, err == nil
}
// EnrolmentUser is the bus user a node enrols as.
func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node }
func (e Enrolment) check() error {
if err := checkName("node", e.Node); err != nil {
return err
}
if _, err := netip.ParseAddr(e.Address); err != nil {
return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address)
}
if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" {
return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node)
}
if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 {
return fmt.Errorf("delivery: the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node)
}
switch e.Stage {
case EnrolmentAsked, EnrolmentLive:
if e.KeysDigest != "" {
return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage)
}
case EnrolmentRedeeming:
if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil {
return err
}
default:
return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage)
}
if e.Issued.IsZero() || !e.Expires.After(e.Issued) {
return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node)
}
return nil
}
// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses.
func EncodeEnrolment(e Enrolment) ([]byte, error) {
if err := e.check(); err != nil {
return nil, err
}
e.V = Version
return json.Marshal(e)
}
// DecodeEnrolment reads a key `node.<node>`; it refuses an unknown version (ErrUnknownVersion), a node that is not a
// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not
// after the issue.
func DecodeEnrolment(data []byte) (Enrolment, error) {
var e Enrolment
if err := decode(data, &e); err != nil {
return Enrolment{}, err
}
if err := e.check(); err != nil {
return Enrolment{}, err
}
return e, nil
}
// EncodeEnrolmentAddress is a key `address.<address>` reserving it for node: with no ask and a zero issued when it is
// reserved, with the issue's ask and time once the ask is raised. It refuses what DecodeEnrolmentAddress refuses.
func EncodeEnrolmentAddress(node, ask string, issued time.Time) ([]byte, error) {
a := EnrolmentAddress{V: Version, Node: node, Ask: ask, Issued: issued}
if err := a.check(); err != nil {
return nil, err
}
return json.Marshal(a)
}
// DecodeEnrolmentAddress reads a key `address.<address>`; it refuses no node, and an ask without the issue's time or
// the reverse.
func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) {
var a EnrolmentAddress
if err := decode(data, &a); err != nil {
return EnrolmentAddress{}, err
}
if err := a.check(); err != nil {
return EnrolmentAddress{}, err
}
return a, nil
}
// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not
// positive or longer than MostTokenLife.
func TokenLife(expires string) (time.Duration, error) {
if strings.TrimSpace(expires) == "" {
return DefaultTokenLife, nil
}
d, err := time.ParseDuration(strings.TrimSpace(expires))
if err != nil || d <= 0 || d > MostTokenLife {
return 0, fmt.Errorf("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires)
}
return d, nil
}
// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the
// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the
// event be named), since the bus deleted the key before the holder heard of it.
type EnrolmentExpiry struct {
V int `json:"v"`
Node string `json:"node"`
IssuedID string `json:"issued-event"`
}
func (x EnrolmentExpiry) check() error {
if err := checkName("node", x.Node); err != nil {
return err
}
if strings.TrimSpace(x.IssuedID) == "" {
return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node)
}
return nil
}
// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses.
func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) {
if err := x.check(); err != nil {
return nil, err
}
x.V = Version
return json.Marshal(x)
}
// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and
// no issued event named.
func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) {
var x EnrolmentExpiry
if err := decode(data, &x); err != nil {
return EnrolmentExpiry{}, err
}
if err := x.check(); err != nil {
return EnrolmentExpiry{}, err
}
return x, nil
}
// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine,
// which live in two repositories.
// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over
// RedeemProof. No token: the bus's login check verified it.
type Redeem struct {
Node string `json:"node"`
PublicKey string `json:"public_key"`
OverlayKey string `json:"overlay_key,omitempty"`
SealingKey string `json:"sealing_key"`
ServingKey string `json:"serving_key,omitempty"`
Profile map[string]any `json:"profile,omitempty"`
Tunnel json.RawMessage `json:"tunnel,omitempty"`
Proof string `json:"proof"`
}
// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it
// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes.
func RedeemProof(node, public, overlay, sealing, serving string) []byte {
return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving)
}
// KeysDigest is the digest of the public keys a redeem presents, `sha256:<hex>`: what the key at redeeming and the
// events carry, the one hash an event carries (§1).
func KeysDigest(public, overlay, sealing, serving string) string {
sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00")))
return "sha256:" + hex.EncodeToString(sum[:])
}
// KeysDigest is r's keys' digest.
func (r Redeem) KeysDigest() string {
return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey)
}
// ProofBytes is r's proof decoded from base64.
func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) }
// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it
// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over
// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries.
type RedeemAnswer struct {
Node string `json:"node"`
Login string `json:"login"`
Account string `json:"account"`
Certificate string `json:"certificate"`
Signer string `json:"signer"`
Signature string `json:"signature,omitempty"`
}
// SignedBytes is what the vault signs of a: a without its signature, as JSON.
func (a RedeemAnswer) SignedBytes() []byte {
a.Signature = ""
raw, _ := json.Marshal(a)
return raw
}
// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone.
const (
EnrolmentTokenVerb = "enrolment-token"
NodeAccountVerb = "node-account"
)
// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its
// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed
// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token
// nor its hash.
type EnrolmentTokenAnswer struct {
Node string `json:"node"`
Broker string `json:"broker,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Signer string `json:"signer,omitempty"`
Sealed string `json:"sealed,omitempty"`
Dropped bool `json:"dropped,omitempty"`
}
// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node
// presented (base64), never in the clear; Retired says a retire left no account.
type NodeAccountAnswer struct {
Node string `json:"node"`
Login string `json:"login,omitempty"`
Account string `json:"account,omitempty"`
Retired bool `json:"retired,omitempty"`
}
+4 -5
View File
@@ -356,17 +356,16 @@ func TestGateSame(t *testing.T) { // 1201/AC6
}
}
func TestGateBuckets(t *testing.T) {
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
// And the buckets and their history are the decided layout (once TestGateBuckets; merged to pay for issue 774's
// token test).
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 ||
TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 {
t.Error("a bucket or history differs from the decided layout")
}
}
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
hh, _ := HeadsHash(gateHeads())
for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} {
if k, err := MergesKey(name); err != nil || k != name {
+137
View File
@@ -0,0 +1,137 @@
package delivery
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// **A node's two logins** (novox/hq ADR 0583, issue 774). A node's bus account rotates between the login `node.<n>` and
// `node.<n>--b`, each with the node's permissions and its inbox `_INBOX.node.<n>` unchanged (§1). Which one a node uses
// is the vault's state `node-logins`, one key per node (§1, insight 2026-11-01), written by the vault alone: the login
// in use, the login rotating if any, and the rotation's stage. Each node-engine reads its own node's key alone; the
// grants part reads them all and composes both logins while a node rotates (§2). The key lives while the node does:
// its rotating part is cleared at rotation-done or abandon, and the key deleted when the node leaves placement.
const (
NodeLoginsState = "node-logins"
NodeLoginsBucket = VaultModule + "_" + NodeLoginsState
NodeLoginsRead = VaultModule + "." + NodeLoginsState
// The stages of a rotation (§2-§4): begun when the vault made the other login's value and wrote it rotating; ready
// when the bus node's have shows a list holding both, said node-account-ready; confirmed when the engine called
// node-account-confirm as the new login. Done (rotation-done) is no stage: the rotating part is cleared.
RotationBegun = "begun"
RotationReady = "ready"
RotationConfirmed = "confirmed"
// The vault's rotation verbs on its seat (§2, §3, §5).
RotateNodeVerb = "rotate-node"
NodeAccountConfirmVerb = "node-account-confirm"
AbandonVerb = "abandon"
// The vault's events of a rotation, said as its own (§2, §4), each carrying the node's NodeLogin.
RotationBegunEvent = "rotation-begun"
NodeAccountReadyEvent = "node-account-ready"
RotationDoneEvent = "rotation-done"
// NodeAccountFallbackFinding is what a node-engine says when it fell back to its old login after repeated
// authentication failures with the new one, before rotation-done; the watchman raises it (§6).
NodeAccountFallbackFinding = "node-account-fallback"
nodeLoginPrefix = "node."
// SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114).
SecondLoginSuffix = "--b"
)
// RotationEventSubject is where the vault says event of a rotation.
func RotationEventSubject(event string) string { return "mesh.mod." + VaultModule + ".event." + event }
// NodeLogin is one key of `node-logins`, the node's name: the login in use, the login rotating to (empty when none, and
// then no stage), the rotation's stage, since when the key says what it says, and the user list's revision that held
// both logins, from ready on.
type NodeLogin struct {
V int `json:"v"`
Node string `json:"node"`
Login string `json:"login"`
Rotating string `json:"rotating,omitempty"`
Stage string `json:"stage,omitempty"`
Since time.Time `json:"since"`
ListRevision uint64 `json:"list-revision,omitempty"`
// Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each.
Acts []string `json:"acts,omitempty"`
}
// FirstLogin is a node's first login, `node.<n>`, the one every node had before rotation; SecondLogin its other.
func FirstLogin(node string) string { return nodeLoginPrefix + node }
func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix }
// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second.
func OtherLogin(node, login string) string {
if login == SecondLogin(node) {
return FirstLogin(node)
}
return SecondLogin(node)
}
// LoginNode is the node a node login names, either of its two, and whether login is one.
func LoginNode(login string) (string, bool) {
rest, ok := strings.CutPrefix(login, nodeLoginPrefix)
rest = strings.TrimSuffix(rest, SecondLoginSuffix)
return rest, ok && checkName("node", rest) == nil
}
// Rotates says the node is rotating: a login rotating is named.
func (l NodeLogin) Rotates() bool { return l.Rotating != "" }
func (l NodeLogin) check() error {
if err := checkName("node", l.Node); err != nil {
return err
}
if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) {
return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login)
}
for _, act := range l.Acts {
if strings.TrimSpace(act) == "" {
return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node)
}
}
if !l.Rotates() {
if l.Stage != "" || l.ListRevision != 0 {
return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage)
}
return nil
}
if l.Rotating != OtherLogin(l.Node, l.Login) {
return fmt.Errorf("delivery: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating)
}
switch l.Stage {
case RotationBegun, RotationReady, RotationConfirmed:
default:
return fmt.Errorf("delivery: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage)
}
return nil
}
// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses.
func EncodeNodeLogin(l NodeLogin) ([]byte, error) {
if err := l.check(); err != nil {
return nil, err
}
l.V = Version
return json.Marshal(l)
}
// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion), a login that is not
// one of the node's two, a rotation to anything but the other, and a stage without a rotation or an unknown one. An
// empty rotating reads as none.
func DecodeNodeLogin(data []byte) (NodeLogin, error) {
var l NodeLogin
if err := decode(data, &l); err != nil {
return NodeLogin{}, err
}
if err := l.check(); err != nil {
return NodeLogin{}, err
}
return l, nil
}
+18 -2
View File
@@ -13,8 +13,8 @@ import (
// what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the
// passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner),
// network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and
// directory-used-as-found (Found). Moved here from mesh-host internal/link (65fbc1e6) unchanged; the engine builds a
// report from its own health statement there (NodeHealthOf).
// directory-used-as-found (Found); and for node-account-fallback (Fallback, ADR 0583 §6). Moved here from mesh-host
// internal/link (65fbc1e6); the engine builds a report from its own health statement there (NodeHealthOf).
// NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does.
const (
@@ -40,6 +40,10 @@ type NodeHealth struct {
Relogin []NodeAccount `json:"relogin,omitempty"`
// Found are the directories used as found.
Found []NodeDirectory `json:"found,omitempty"`
// Fallback is set when the engine fell back to its node's old login after the new one failed its test-dial or its
// confirm (novox/hq ADR 0583 §6): the watchman raises node-account-fallback from it. Nil while the node logs in as
// its login in force.
Fallback *LoginFallback `json:"fallback,omitempty"`
// Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq
// ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root
// judge an agent's confinement by, on the machine's own word.
@@ -51,6 +55,15 @@ type NodeHealth struct {
At time.Time `json:"at"`
}
// LoginFallback is a node's fall back to its old login (ADR 0583 §6): the node, the login it fell back from (the one the
// rotation moved to), why, in words with no credential, and when.
type LoginFallback struct {
Node string `json:"node"`
From string `json:"from"`
Why string `json:"why"`
At time.Time `json:"at"`
}
// NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part.
type NetworkHealth struct {
State string `json:"state"`
@@ -150,6 +163,9 @@ func EncodeNodeHealth(h NodeHealth) ([]byte, error) {
if h.Node == "" {
return nil, errors.New("a node's health report names its node")
}
if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) {
return nil, fmt.Errorf("a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node)
}
return json.Marshal(h)
}
+141 -1
View File
@@ -1,10 +1,13 @@
package delivery
import (
"encoding/base64"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
@@ -13,7 +16,7 @@ import (
// check (ADR 0201 §13) refuses such a field in a state every node reads.
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
// neither it nor the placement file names a field like a secret.
func TestNodeRecord(t *testing.T) {
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
@@ -68,6 +71,143 @@ func TestNodeRecord(t *testing.T) {
}
}
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
// 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the
// grants part reads them (ADR 0587 §2).
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
// The tunnel key given at issue, at every stage.
wg := base64.StdEncoding.EncodeToString(make([]byte, 32))
for _, e := range []Enrolment{
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive,
OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)},
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming,
OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")},
} {
raw, err := EncodeEnrolment(e)
if err != nil {
t.Fatal(err)
}
back, err := DecodeEnrolment(raw)
e.V = Version
if err != nil || !reflect.DeepEqual(back, e) {
t.Errorf("enrolment round trip: %+v, %v", back, err)
}
}
// ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both.
if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil {
t.Fatal(err)
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") {
t.Errorf("address round trip: %+v, %v (%s)", back, err, raw)
}
if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil {
t.Fatal(err)
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) {
t.Errorf("address at the ask round trip: %+v, %v", back, err)
}
for name, raw := range map[string]string{
"no node": `{"v":1}`,
"an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`,
"an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`,
} {
if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil {
t.Errorf("an address with %s: taken", name)
}
}
if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) {
t.Errorf("the issued event's id: %q", id)
}
for name, e := range map[string]Enrolment{
"keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")},
"no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming,
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
"no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
"no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued,
Expires: issued.Add(time.Hour)},
"a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked,
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)},
"other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued,
Expires: issued.Add(time.Hour)},
} {
if _, err := EncodeEnrolment(e); err == nil {
t.Errorf("enrolment with %s: taken", name)
}
}
if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" {
t.Errorf("address key: %q %v", key, ok)
}
if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" {
t.Errorf("node key: %q %v", node, ok)
}
for _, life := range []string{"", "1h", "24h"} {
if _, err := TokenLife(life); err != nil {
t.Errorf("a life of %q: %v", life, err)
}
}
for _, life := range []string{"25h", "-1h", "a day"} {
if _, err := TokenLife(life); err == nil {
t.Errorf("a life of %q: taken", life)
}
}
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
for _, l := range []NodeLogin{
{Node: "g14", Login: "node.g14", Since: issued},
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
} {
raw, err := EncodeNodeLogin(l)
if err != nil {
t.Fatal(err)
}
back, err := DecodeNodeLogin(raw)
l.V = Version
if err != nil || !reflect.DeepEqual(back, l) {
t.Errorf("node login round trip: %+v, %v", back, err)
}
}
// The hand acts that warranted its rotations, as the vault records them.
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
!strings.Contains(string(raw), `"acts":["act-1"]`) {
t.Errorf("a login with its acts: %s, %v", raw, err)
}
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
t.Errorf("an empty rotating: %+v, %v", l, err)
}
for name, l := range map[string]NodeLogin{
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
"another node's login": {Node: "g14", Login: "node.ace"},
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
} {
if _, err := EncodeNodeLogin(l); err == nil {
t.Errorf("node login with %s: taken", name)
}
}
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
t.Errorf("the second login's node: %q %v", node, ok)
}
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
// enrolment-expired names the issued event by its message id (§2), never a stream sequence.
if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil {
t.Errorf("an expiry: %v", err)
} else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" ||
!strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) {
t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw)
}
for name, raw := range map[string]string{
"no issued event": `{"v":1,"node":"lab"}`,
"a sequence for it": `{"v":1,"node":"lab","issued-event":7}`,
"a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`,
} {
if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil {
t.Errorf("an expiry with %s: taken", name)
}
}
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
walk(reflect.TypeOf(Placement{}), "Placement")
}
+134 -24
View File
@@ -65,6 +65,13 @@ const (
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
OpConfig = "config" // set a module's config placement or values on the node
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
// enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change.
OpAddNode = "add-node"
// OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired
// (the operator's decision of 2026-11-02 01:05): mesh-enrolment's alone, on the same warrant, the proposal's one
// change, of a node with no module and no holding. The holder deletes its key (RemovedNodes).
OpRemoveNode = "remove-node"
)
// The states of an open proposal.
@@ -107,31 +114,34 @@ type PlacementProposalChange struct {
Values map[string]any `json:"values,omitempty"`
// Whole is the node's whole placement (OpSeed).
Whole *Placement `json:"whole,omitempty"`
// Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among
// them, and the public halves of its keys: at the operator's yes its tunnel key alone, given at issue (the operator's
// decision of 2026-11-02 01:05); the identity keys are bound at redeem, checked against the node account's digest.
Decided *NodeDecided `json:"decided,omitempty"`
Keys *JoiningKeys `json:"keys,omitempty"`
}
// BeforeLease is why the change cannot be proposed before the lease write, or "" (ADR 0567 §8): until then the holder
// keeps the controller's assignments in step through its verbs assign, unassign, pin and unpin, so only those are
// proposable — a module placed or taken off with no host port or binding, a pin set or removed, a module's config values
// and the seed. A host port, a binding, a holding, a decided field and a config layer's places or accesses wait for the
// lease write, which the refusal names.
func (c PlacementProposalChange) BeforeLease() string {
const lease = "which the controller's verbs cannot take from a caller, so it waits for the lease write (ADR 0567 §8)"
switch c.Op {
case OpPlace:
if len(c.Ports)+len(c.Bindings) > 0 {
return fmt.Sprintf("the place of %s on %s names host ports or bindings, %s", c.Module, c.Node, lease)
}
case OpTakeOff, OpPin, OpUnpin, OpSeed:
case OpConfig:
// A module's config values are the state's alone (§13); its layers' places and accesses are the controller's
// until the lease write.
if c.MeshWide != nil || c.Layer != nil {
return fmt.Sprintf("the config of %s on %s sets a layer's places or accesses, %s", c.Module, c.Node, lease)
}
default:
return fmt.Sprintf("a change %s of %s is no assignment or pin, %s", c.Op, c.Node, lease)
}
return ""
// JoiningKeys are the public halves of a joining node's keys (ADR 0587 §6, §7): its private network (tunnel) key, given
// at issue, and its identity, sealing and serving keys, presented at redeem, as Redeem carries them. An add-node at the
// operator's yes carries the tunnel key alone. Public halves alone, never a private key.
type JoiningKeys struct {
Identity string `json:"identity"`
Overlay string `json:"overlay,omitempty"`
Sealing string `json:"sealing"`
Serving string `json:"serving,omitempty"`
}
// Digest is the keys' digest, as the enrolment's key holds it (KeysDigest).
func (k JoiningKeys) Digest() string { return KeysDigest(k.Identity, k.Overlay, k.Sealing, k.Serving) }
// AddNodeWarrant is the warrant an add-node carries (ADR 0587 §7): the enrolment's node and address as its issue's ask
// was bound to them, the ask's id, and the warrant's id as the teller wrote it. mesh-placement checks it names the
// change's node and address, and that the caller is mesh-enrolment as the runtime proves it (AddNodeRefusal).
type AddNodeWarrant struct {
Node string `json:"node"`
Address string `json:"address"`
Ask string `json:"ask"`
Warrant string `json:"warrant"`
}
func (c PlacementProposalChange) check() error {
@@ -160,8 +170,28 @@ func (c PlacementProposalChange) check() error {
return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node)
}
return c.Whole.check()
case OpAddNode:
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
return fmt.Errorf("delivery: the add-node of %s names no address or no tunnel key", c.Node)
}
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
return fmt.Errorf("delivery: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
}
if c.Decided.Hub {
return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node)
}
if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil {
return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
}
return nil
case OpRemoveNode:
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
return fmt.Errorf("delivery: the remove-node of %s names more than its node", c.Node)
}
return nil
}
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide or config", c.Op)
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op)
}
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
@@ -200,6 +230,23 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange)
switch c.Op {
case OpSeed:
p = clonePlacement(*c.Whole)
case OpAddNode:
// A joining node has no key yet (ADR 0587 §7): one that has is no joining node.
if _, has := current[c.Node]; has {
findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node))
continue
}
p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}}
case OpRemoveNode:
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
// anything is taken off module by module, by a proposal the operator approves).
cur, has := current[c.Node]
if !has {
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
} else if len(cur.Modules)+len(cur.Holds) > 0 {
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
}
continue
case OpPlace:
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
if at >= 0 {
@@ -403,6 +450,9 @@ type PlacementProposal struct {
// Again is when `again` asked the holder to judge it now and raise its ask again (§10); the holder does once per
// value.
Again time.Time `json:"again,omitempty"`
// Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7): its Approve is the issue's, so the holder
// writes it with no ask of its own. Only an add-node carries one.
Warrant *AddNodeWarrant `json:"warrant,omitempty"`
// Withdrawn is who withdrew it and why (`withdraw`, §2): the holder then deletes it and its candidates and says
// placement-withdrawn, which only the holder's process can say on its stream.
Withdrawn string `json:"withdrawn,omitempty"`
@@ -444,9 +494,69 @@ func (p PlacementProposal) check() error {
if p.Seed && p.ExportDigest == "" {
return fmt.Errorf("delivery: the seed %s names no export digest", p.ID)
}
return p.checkAddNode()
}
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
// proposal (ADR 0587 §7).
func (p PlacementProposal) checkAddNode() error {
adds := 0
for _, c := range p.Changes {
if c.Op == OpAddNode || c.Op == OpRemoveNode {
adds++
}
}
if adds == 0 {
if p.Warrant != nil {
return fmt.Errorf("delivery: the proposal %s carries an enrolment's warrant and adds no node", p.ID)
}
return nil
}
if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed {
return fmt.Errorf("delivery: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID)
}
c, w := p.Changes[0], p.Warrant
if w == nil || w.Ask == "" || w.Warrant == "" {
return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node)
}
if c.Op == OpRemoveNode {
if w.Node != c.Node {
return fmt.Errorf("delivery: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
}
return nil
}
if w.Node != c.Node || w.Address != c.Decided.Address {
return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address,
w.Node, w.Address)
}
return nil
}
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
func (p PlacementProposal) RemovedNodes() []string {
var out []string
for _, c := range p.Changes {
if c.Op == OpRemoveNode {
out = append(out, c.Node)
}
}
return out
}
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node or a remove-node from caller, the module the runtime proved
// called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal
// refuses.
func AddNodeRefusal(p PlacementProposal, caller string) string {
if err := p.checkAddNode(); err != nil {
return err.Error()
}
if p.Warrant != nil && caller != EnrolmentModule {
return fmt.Sprintf("an add-node or a remove-node is mesh-enrolment's alone, and %s proposed it", caller)
}
return ""
}
// EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses.
func EncodePlacementProposal(p PlacementProposal) ([]byte, error) {
if err := p.check(); err != nil {
+86 -13
View File
@@ -651,19 +651,6 @@ func TestPlacement(t *testing.T) {
t.Errorf("%s: %v, want a finding naming %q", name, f, c.want)
}
}
for change, waits := range map[*PlacementProposalChange]bool{
{Op: OpPlace, Node: "g14", Module: "x"}: false, {Op: OpTakeOff, Node: "g14", Module: "x"}: false,
{Op: OpPin, Node: "g14"}: false, {Op: OpUnpin, Node: "g14"}: false, {Op: OpSeed, Node: "g14"}: false,
{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"80": 80}}: true,
{Op: OpPlace, Node: "g14", Module: "x", Bindings: map[string]Provider{"p": {}}}: true,
{Op: OpHold, Node: "g14"}: true, {Op: OpDecide, Node: "g14"}: true,
{Op: OpConfig, Node: "g14", Values: map[string]any{"puid": 1}}: false, {Op: OpConfig, Node: "g14", MeshValues: map[string]any{"a": 1}}: false,
{Op: OpConfig, Node: "g14", Layer: map[string]any{"places": 1}}: true, {Op: OpConfig, Node: "g14", MeshWide: map[string]any{"accesses": 1}}: true,
} {
if got := change.BeforeLease(); (got != "") != waits || (waits && !strings.Contains(got, "lease write")) {
t.Errorf("%+v before the lease write: %q", *change, got)
}
}
const id = "p-20261028t183000-1a2b3c"
if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || CheckProposalID(got) != nil {
@@ -702,6 +689,92 @@ func TestPlacement(t *testing.T) {
if ChangesDigest(p) == ChangesDigest(seed) {
t.Error("two proposals' changes digest alike")
}
// 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one
// change, from mesh-enrolment alone; a warrant naming another node or address is refused.
// At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05).
joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"},
Keys: &JoiningKeys{Overlay: "wg"}}
add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(),
Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering,
Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}}
if raw, err := EncodePlacementProposal(add); err != nil {
t.Errorf("an add-node: %v", err)
} else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" {
t.Errorf("an add-node round trip: %+v, %v", back, err)
}
if AddNodeRefusal(add, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") {
t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, EnrolmentModule), AddNodeRefusal(add, AssignerModule))
}
if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 {
t.Errorf("an add-node laid: %+v, %v", laid, f)
}
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"},
Keys: &JoiningKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
t.Errorf("an add-node of a placed node: %v", f)
}
for name, mut := range map[string]func(*PlacementProposal){
"warrant of another node": func(p *PlacementProposal) {
p.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
},
"warrant of another address": func(p *PlacementProposal) {
p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"}
},
"no warrant": func(p *PlacementProposal) { p.Warrant = nil },
"no tunnel key": func(p *PlacementProposal) {
c := joining
c.Keys = &JoiningKeys{Identity: "id", Sealing: "seal"}
p.Changes = []PlacementProposalChange{c}
},
"half its identity keys": func(p *PlacementProposal) {
c := joining
c.Keys = &JoiningKeys{Overlay: "wg", Identity: "id"}
p.Changes = []PlacementProposalChange{c}
},
"beside another change": func(p *PlacementProposal) {
p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"})
},
"the hub": func(p *PlacementProposal) {
c := joining
c.Decided = &NodeDecided{Address: "10.0.0.7", Hub: true}
p.Changes = []PlacementProposalChange{c}
},
"a warrant on a move": func(p *PlacementProposal) {
p.Changes, p.Base = []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]uint64{"g14": 4}
},
} {
q := add
q.Changes = append([]PlacementProposalChange(nil), add.Changes...)
mut(&q)
if _, err := EncodePlacementProposal(q); err == nil {
t.Errorf("an add-node with %s: taken", name)
}
}
// With its identity keys whole, as bound at redeem, it is taken too.
whole := add
whole.Changes = []PlacementProposalChange{joining}
whole.Changes[0].Keys = &JoiningKeys{Overlay: "wg", Identity: "id", Sealing: "seal"}
if _, err := EncodePlacementProposal(whole); err != nil {
t.Errorf("an add-node with its identity keys: %v", err)
}
// An enrolment's end takes its never-confirmed node off again: mesh-enrolment's alone, of a node running nothing.
remove := add
remove.Changes = []PlacementProposalChange{{Op: OpRemoveNode, Node: "lab"}}
if _, err := EncodePlacementProposal(remove); err != nil || AddNodeRefusal(remove, AssignerModule) == "" ||
AddNodeRefusal(remove, EnrolmentModule) != "" || !slices.Equal(remove.RemovedNodes(), []string{"lab"}) {
t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, AssignerModule))
}
joined := map[string]Placement{"lab": {V: Version, Node: "lab", Decided: NodeDecided{Address: "10.0.0.7"}, Modules: []PlacedModule{}}}
if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 {
t.Errorf("a joining node removed: %v, %v", laid, f)
}
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 ||
!strings.Contains(f[0], "runs modules") {
t.Errorf("a running node removed: %v", f)
}
remove.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
if _, err := EncodePlacementProposal(remove); err == nil {
t.Error("a remove-node on another node's warrant: taken")
}
for name, mut := range map[string]func(*PlacementProposal){
"no id": func(p *PlacementProposal) { p.ID = "" },
"no proposer": func(p *PlacementProposal) { p.By = "" },
+2 -2
View File
@@ -48,8 +48,8 @@ const (
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
KindNodeCertificate = "node-certificate"
// KindEnrolment is an enrolment reply, signed under its own context; no caller is named until adoption's record
// (ADR 0561 §5).
// KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for mesh-enrolment alone (ADR 0587
// §3, §6).
KindEnrolment = "enrolment"
// KindAuthority is the vault's own authority certificate, which the vault signs for itself.
KindAuthority = "authority"
+3
View File
@@ -369,6 +369,9 @@ type CheckJob struct {
// has to list them. The build step hands them to its test tool as the argument NodeNamesArg, and the build module
// gives them to the tests in NodeNamesEnv.
NodeNames []string `json:"node-names,omitempty"`
// Toolchain is the toolchain the checker agreed for the check's language (novox/hq ADR 0584): a node building with
// another declines the ask, so a pass is never recorded as run on a version it did not run on. Nil names none.
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
}
// NodeNamesArg is the test tool's argument that carries a check's node names, and NodeNamesEnv the environment
+118
View File
@@ -0,0 +1,118 @@
package delivery
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the bus is, what certificate
// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the
// machine's tunnel key, the machine's first tunnel (ADR 0169).
//
// Moved here from the node-engine (mesh-host internal/identity, which keeps an alias) so the vault, which makes the
// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the
// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue`
// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of
// ADR 0587 §3, 2026-11-02).
type Token struct {
Version int `json:"v"`
// Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before
// the mesh can say anything.
Node string `json:"node,omitempty"`
Broker string `json:"broker,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Signer []byte `json:"signer,omitempty"`
Secret string `json:"secret"`
// Adopted says this node joins adopted (novox/hq ADR 0100); absent for a converged node.
Adopted bool `json:"adopted,omitempty"`
// Tunnel is this machine's first tunnel, when the token was issued for the key it made (novox/hq ADR 0169): its own
// address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet.
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
}
// TokenTunnel is the joining machine's side of its first tunnel, in the field names the controller writes.
type TokenTunnel struct {
Key string `json:"key"`
Address string `json:"address"`
Range string `json:"range"`
HubKey string `json:"hub_key"`
HubEndpoint string `json:"hub_endpoint"`
}
// ParseToken reads a token a person pasted. Every refusal says *this is not a token* rather than *this is the wrong
// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token
// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects
// to whatever answers, without the signing key it cannot tell a declaration from a forgery.
func ParseToken(encoded string) (Token, error) {
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return Token{}, fmt.Errorf("this is not a token: %w", err)
}
var t Token
if err := json.Unmarshal(raw, &t); err != nil {
return Token{}, fmt.Errorf("this is not a token: %w", err)
}
if t.Version != 1 {
return Token{}, fmt.Errorf(
"this token says it is version %d, and this host understands version 1", t.Version)
}
if missing := t.Missing(); len(missing) > 0 {
return Token{}, fmt.Errorf(
"this token is missing %s, so it cannot be used to join anything",
strings.Join(missing, ", "))
}
return t, nil
}
// Missing names every part of t a joining machine needs and t lacks.
func (t Token) Missing() []string {
var missing []string
if strings.TrimSpace(t.Broker) == "" {
missing = append(missing, "the broker's address")
}
if strings.TrimSpace(t.Fingerprint) == "" {
missing = append(missing, "the broker certificate's fingerprint")
}
if len(t.Signer) != ed25519.PublicKeySize {
missing = append(missing, "the vault's signing key")
}
if strings.TrimSpace(t.Secret) == "" {
missing = append(missing, "the one-time secret")
}
if tt := t.Tunnel; tt != nil {
for _, part := range []struct{ value, says string }{
{tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"},
{tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"},
{tt.HubEndpoint, "where the hub's tunnel is dialled"},
} {
if strings.TrimSpace(part.value) == "" {
missing = append(missing, part.says)
}
}
}
return missing
}
// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the
// controller's `token issue` writes it. Refused when a part is missing.
func (t Token) Encode() (string, error) {
t.Version = 1
if missing := t.Missing(); len(missing) > 0 {
return "", fmt.Errorf("delivery: the token is missing %s", strings.Join(missing, ", "))
}
raw, err := json.Marshal(t)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after.
func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) }
+53
View File
@@ -0,0 +1,53 @@
package delivery
import (
"encoding/hex"
"reflect"
"strings"
"testing"
)
// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9,
// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519
// key of the seed 00 01 .. 1f. Copied, never written by hand.
const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19"
// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it
// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens.
func TestATokenTheControllerMadeReads(t *testing.T) {
signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8")
want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222",
Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true,
Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24",
HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}}
got, err := ParseToken(" " + controllerToken + "\n")
if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 {
t.Fatalf("parsed %+v, %v", got, err)
}
if again, err := got.Encode(); err != nil || again != controllerToken {
t.Errorf("encoded again: %q, %v", again, err)
}
for _, c := range []struct {
cut func(*Token)
expect string
}{
{func(t *Token) { t.Broker = "" }, "broker's address"},
{func(t *Token) { t.Fingerprint = "" }, "fingerprint"},
{func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"},
{func(t *Token) { t.Secret = "" }, "one-time secret"},
{func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"},
} {
cut := want
cut.Tunnel = &TokenTunnel{}
*cut.Tunnel = *want.Tunnel
c.cut(&cut)
if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) {
t.Errorf("a token without %s: %v", c.expect, err)
}
}
for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} {
if _, err := ParseToken(bad); err == nil {
t.Errorf("%q parsed as a token", bad)
}
}
}
+42 -10
View File
@@ -99,22 +99,47 @@ type NodeToolchain struct {
Toolchain
}
// AgreedToolchain is the one Toolchain2 every node's entry of a language agrees on, or none and why: "toolchain not
// yet reported" with no entries, and "builders disagree" naming each differing version and recipe with the nodes
// reporting it when two differ (novox/hq ADR 0405 clause 2). The answer does not depend on the entries' order. The
// entries must all be of one language.
func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
// SittingOut is a node whose toolchain of a language is not the agreed one: it builds nothing of that language until
// it reports the agreed version (novox/hq ADR 0584 clause 2).
type SittingOut struct {
Node string
Version string
Recipe string
}
// Agreement is what the nodes' entries of one language come to (novox/hq ADR 0584). Toolchain is the agreed one, set
// when Wait is empty. Wait is why nothing is agreed: "toolchain not yet reported" with no entries, or "builders
// disagree" naming each version and recipe with the nodes reporting it, when no version has a strict majority.
// SittingOut lists, sorted by node, the nodes that report another version than the agreed one.
type Agreement struct {
Toolchain Toolchain2
SittingOut []SittingOut
Wait string
}
// ToolchainAgreement is the agreed toolchain of a language: the version and recipe a strict majority of the
// reporting nodes report (novox/hq ADR 0584 clause 1). With no strict majority (two against two) or no entry it
// gives no toolchain and says why in Wait. The answer does not depend on the entries' order. The entries must all be
// of one language.
func ToolchainAgreement(entries []NodeToolchain) Agreement {
if len(entries) == 0 {
return Toolchain2{}, ToolchainNoReport
return Agreement{Wait: ToolchainNoReport}
}
groups := map[Toolchain2][]string{}
for _, e := range entries {
k := Toolchain2{Language: e.Language, Version: e.Version, Recipe: e.Recipe}
groups[k] = append(groups[k], e.Node)
}
if len(groups) == 1 {
for k := range groups {
return k, ""
for k, nodes := range groups {
if 2*len(nodes) > len(entries) {
a := Agreement{Toolchain: k}
for _, e := range entries {
if e.Version != k.Version || e.Recipe != k.Recipe {
a.SittingOut = append(a.SittingOut, SittingOut{Node: e.Node, Version: e.Version, Recipe: e.Recipe})
}
}
sort.Slice(a.SittingOut, func(i, j int) bool { return a.SittingOut[i].Node < a.SittingOut[j].Node })
return a
}
}
parts := make([]string, 0, len(groups))
@@ -123,5 +148,12 @@ func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
parts = append(parts, fmt.Sprintf("%s recipe %s on %s", k.Version, k.Recipe, strings.Join(nodes, ", ")))
}
sort.Strings(parts)
return Toolchain2{}, "builders disagree: " + strings.Join(parts, "; ")
return Agreement{Wait: "builders disagree: " + strings.Join(parts, "; ")}
}
// AgreedToolchain is ToolchainAgreement's toolchain, or none and why (its Wait): the version a strict majority of the
// nodes report (novox/hq ADR 0584, which changes ADR 0405 clause 2 and ADR 0477 clause 2).
func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
a := ToolchainAgreement(entries)
return a.Toolchain, a.Wait
}
+44 -30
View File
@@ -55,36 +55,50 @@ func TestToolchainKeyAndValue(t *testing.T) {
}
}
// 1443/AC2
func TestAgreedToolchain(t *testing.T) {
tc, none := AgreedToolchain([]NodeToolchain{tcEntry("ace", "go", "1.26.1", "3"), tcEntry("novox", "go", "1.26.1", "3")})
if tc != (Toolchain2{Language: "go", Version: "1.26.1", Recipe: "3"}) || none != "" {
t.Errorf("%+v %q", tc, none)
}
}
// 1443/AC3
func TestAgreedToolchainNoReport(t *testing.T) {
tc, none := AgreedToolchain(nil)
if tc != (Toolchain2{}) || none != "toolchain not yet reported" {
t.Errorf("%+v %q", tc, none)
}
}
// 1443/AC4
func TestAgreedToolchainDisagree(t *testing.T) {
a, b, c := tcEntry("ace", "go", "1.26.1", "3"), tcEntry("novox", "go", "1.26.2", "3"), tcEntry("g14", "go", "1.26.1", "4")
_, first := AgreedToolchain([]NodeToolchain{a, b, c})
_, second := AgreedToolchain([]NodeToolchain{c, b, a})
if first == "" || first != second {
t.Errorf("order changed the answer: %q vs %q", first, second)
}
for _, want := range []string{"builders disagree", "1.26.1", "1.26.2", "ace", "novox", "g14"} {
if !strings.Contains(first, want) {
t.Errorf("%q does not name %q", first, want)
// 1443/AC2-4, ADR 0584: the agreed toolchain is the strict majority's; a minority node sits out; a tie waits.
func TestToolchainAgreement(t *testing.T) {
e := tcEntry
for _, c := range []struct {
name string
in []NodeToolchain
version string
out []SittingOut
wait []string // "" wait means agreed
}{
{"all agree", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.1", "3")}, "1.26.1", nil, nil},
{"three against one", []NodeToolchain{e("ace", "typescript", "26.12.1", "3"), e("g14", "typescript", "26.13.0", "3"), e("novox", "typescript", "26.13.0", "3"), e("shanks", "typescript", "26.13.0", "3")},
"26.13.0", []SittingOut{{"ace", "26.12.1", "3"}}, nil},
{"two against two", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.2", "3"), e("g14", "go", "1.26.1", "3"), e("shanks", "go", "1.26.2", "3")},
"", nil, []string{"builders disagree", "1.26.1 recipe 3 on ace, g14", "1.26.2 recipe 3 on novox, shanks"}},
{"one node alone", []NodeToolchain{e("ace", "go", "1.26.1", "3")}, "1.26.1", nil, nil},
{"no report", nil, "", nil, []string{ToolchainNoReport}},
} {
a := ToolchainAgreement(c.in)
rev := make([]NodeToolchain, len(c.in))
for i, x := range c.in {
rev[len(c.in)-1-i] = x
}
if b := ToolchainAgreement(rev); b.Wait != a.Wait || b.Toolchain != a.Toolchain {
t.Errorf("%s: order changed the answer: %+v vs %+v", c.name, a, b)
}
if a.Toolchain.Version != c.version || len(a.SittingOut) != len(c.out) {
t.Errorf("%s: %+v", c.name, a)
}
for i := range c.out {
if a.SittingOut[i] != c.out[i] {
t.Errorf("%s: sitting out %+v, want %+v", c.name, a.SittingOut, c.out)
}
}
if (len(c.wait) == 0) != (a.Wait == "") {
t.Errorf("%s: wait %q", c.name, a.Wait)
}
for _, w := range c.wait {
if !strings.Contains(a.Wait, w) {
t.Errorf("%s: %q does not name %q", c.name, a.Wait, w)
}
}
if tc, none := AgreedToolchain(c.in); tc != a.Toolchain || none != a.Wait {
t.Errorf("%s: AgreedToolchain %+v %q", c.name, tc, none)
}
}
if tc, _ := AgreedToolchain([]NodeToolchain{a, b}); tc != (Toolchain2{}) {
t.Errorf("a disagreement gave %+v", tc)
}
}
+4
View File
@@ -65,6 +65,10 @@ type GrantsUserList struct {
// LeftOut are the users the list leaves out for want of a hash in the vault.
LeftOut []string `json:"left_out"`
At time.Time `json:"at"`
// LeaseHanded says the controller's lease is handed to the grants part (novox/hq ADR 0582 clause 3, ADR 0571 clause
// 8): from then on the bus node follows this list alone and never falls back to the controller's. A key written
// before it reads false.
LeaseHanded bool `json:"lease-handed,omitempty"`
}
// EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow.
+11 -28
View File
@@ -1,8 +1,7 @@
// Package handacts is the hand-act log's contract (novox/hq ADR 0565 clauses 10 and 11, issue 1953): the layout of an act,
// moved unchanged from the controller's (mesh-controller internal/link HandAct) so one reader reads both logs alike; the
// names of the seat mesh-hand-acts and of what its holder keeps; and the recorder every module records through, which
// asks the seat and, while no holder answers, the controller's verb until the controller hands its lease away, saying
// which log took the act.
// asks the seat alone: the controller's hand-act and warranted are gone (issue 1953 L5b) and the bus refuses them.
package handacts
import (
@@ -95,13 +94,13 @@ type Taken struct {
Answer json.RawMessage `json:"answer,omitempty"`
}
// Recorder records acts for a module (ADR 0565 clause 11): through the seat mesh-hand-acts, and while the seat has no
// holder that answers, through the controller's verb until the controller hands its lease away (the release's order
// gives no seat's holder before its callers; 00-plan.md, "Missing" 1).
// Recorder records acts for a module (ADR 0565 clause 11) through the seat mesh-hand-acts alone. An act the seat does
// not take is an error that says nothing was recorded, never a quiet success.
type Recorder struct {
// Ask calls a seat verb by its address (`seat:<seat>.<verb>`): stdio.Ask through the runtime.
Ask func(key string, body any) (json.RawMessage, error)
// LeaseAway says the controller's lease is handed away: then nothing falls back to it. Nil is never handed away.
// LeaseAway is no longer read: nothing falls back to the controller. Kept so a caller that sets it builds; it goes
// once no module sets it.
LeaseAway func() bool
}
@@ -121,38 +120,22 @@ func (r Recorder) Record(q Request) (Taken, error) {
body[k] = v
}
}
controller := map[string]any{"what": q.What, "why": q.Why, "cause": q.Cause}
if q.Condition != "" {
controller["condition"] = q.Condition
}
if q.Cause == "" {
controller["cause"] = q.Verb
}
return r.either("hand-act", body, "hand-act", controller)
return r.record("hand-act", body)
}
// RecordWarrant records what the asking module did on the operator's warrant for its ask.
func (r Recorder) RecordWarrant(asker, ask string) (Taken, error) {
body := map[string]any{"asker": asker, "ask": ask}
return r.either("warranted", body, "warranted", body)
return r.record("warranted", body)
}
func (r Recorder) either(verb string, body any, controllerVerb string, controllerBody any) (Taken, error) {
func (r Recorder) record(verb string, body any) (Taken, error) {
answer, err := r.Ask("seat:"+Seat+"."+verb, body)
if err == nil {
return Taken{Log: LogSeat, Answer: answer}, nil
}
if !NoResponder(err) {
return Taken{}, fmt.Errorf("%s.%s refused: %w", Seat, verb, err)
if NoResponder(err) {
return Taken{}, fmt.Errorf("%s has no holder that answers: nothing was recorded (%w)", Seat, err)
}
if r.LeaseAway != nil && r.LeaseAway() {
return Taken{}, fmt.Errorf("%s has no holder that answers, and the controller handed its lease away: nothing "+
"was recorded (%w)", Seat, err)
}
answer, cerr := r.Ask("seat:mesh-controller."+controllerVerb, controllerBody)
if cerr != nil {
return Taken{}, fmt.Errorf("%s has no holder that answers (%v), and the controller's %s refused too: %w", Seat, err,
controllerVerb, cerr)
}
return Taken{Log: LogController, Answer: answer}, nil
return Taken{}, fmt.Errorf("%s.%s refused, nothing was recorded: %w", Seat, verb, err)
}
+8 -10
View File
@@ -8,9 +8,8 @@ import (
"testing"
)
// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4): an act reads the controller's entries unchanged, and a recorder
// records through the seat, through the controller's verb only while the seat has no holder and the lease is the
// controller's, saying which log took it, and refuses to record nowhere.
// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4, L5b): an act reads the controller's entries unchanged, and a
// recorder records through the seat alone, the controller's verbs being gone, and says loudly when nothing was recorded.
func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) {
// The shape of mesh-controller internal/link HandAct, as its bucket keeps an entry (handacts.go at 0fbb5ef3).
raw := `{"id":"act-1","at":"2026-10-28T09:00:00Z","by":"person.jochen","verb":"hand-act record","why":"a stuck consumer",` +
@@ -39,14 +38,13 @@ func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) {
warrants bool
}{
{name: "the seat answers", log: LogSeat, asked: "seat:mesh-hand-acts.hand-act"},
{name: "no holder yet", seat: noResponders, log: LogController, asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"},
{name: "no holder, the lease handed away", seat: noResponders, away: true, refused: "handed its lease away",
{name: "no holder: nothing recorded, the controller never asked", seat: noResponders, refused: "nothing was recorded",
asked: "seat:mesh-hand-acts.hand-act"},
{name: "the seat refuses", seat: errors.New("why is required"), refused: "refused", asked: "seat:mesh-hand-acts.hand-act"},
{name: "neither answers", seat: noResponders, ctrl: noResponders, refused: "refused too",
asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"},
{name: "a warrant without a holder", seat: noResponders, warrants: true, log: LogController,
asked: "seat:mesh-hand-acts.warranted,seat:mesh-controller.warranted"},
{name: "the seat refuses", seat: errors.New("why is required"), refused: "refused, nothing was recorded",
asked: "seat:mesh-hand-acts.hand-act"},
{name: "a warrant without a holder", seat: noResponders, warrants: true, refused: "nothing was recorded",
asked: "seat:mesh-hand-acts.warranted"},
{name: "a warrant recorded", warrants: true, log: LogSeat, asked: "seat:mesh-hand-acts.warranted"},
} {
var asked []string
r := Recorder{LeaseAway: func() bool { return c.away }, Ask: func(key string, body any) (json.RawMessage, error) {
+7 -15
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/membership"
@@ -17,10 +16,10 @@ import (
// not a membership is handed to onBad (nil to ignore it) and skipped.
//
// It reads the membership from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7),
// watched so the value there now comes first and each change after it; and it hears the subject the grants part
// publishes on until the lease write. The assignments stream is no longer read: the bus module's tools delete it
// at the lease write (research 072 row 33). A state not there yet, or not readable, is told to onBad and the
// subject is followed alone. The returned error is a refusal to follow at all.
// watched so the value there now comes first and each change after it. Nothing else: the subject the grants part
// published on is no longer heard (ADR 0571 clause 10, issue 774), since it stops publishing at the lease write, and
// the assignments stream is no longer read (research 072 row 33). A state not there or not readable is a refusal to
// follow, returned: there is nothing else to follow.
func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(error)) error {
if c.account.Node == "" || c.account.Module == "" {
return fmt.Errorf("the account names no node or module, so it has no membership")
@@ -38,27 +37,20 @@ func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(er
}
apply(issued)
}
subject := membership.Subject(c.account.Node, c.account.Module)
if _, err := c.nc.Subscribe(subject, func(m *nats.Msg) { take(m.Data) }); err != nil {
return fmt.Errorf("cannot follow %s: %w", subject, err)
}
key := membership.GrantsKey(c.account.Node, c.account.Module)
js, err := jetstream.New(c.nc)
if err != nil {
bad(fmt.Errorf("cannot read %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject))
return nil
return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
kv, err := js.KeyValue(ctx, membership.GrantsBucket)
if err != nil {
bad(fmt.Errorf("cannot read %s of %s yet (%v); following %s alone", key, membership.GrantsBucket, err, subject))
return nil
return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err)
}
w, err := kv.Watch(context.Background(), key)
if err != nil {
bad(fmt.Errorf("cannot watch %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject))
return nil
return fmt.Errorf("cannot watch %s of %s: %w", key, membership.GrantsBucket, err)
}
go func() {
for e := range w.Updates() {
+41 -9
View File
@@ -3,14 +3,20 @@
package process
import (
"context"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// A process connected pinned to a test bus hears the membership published for its node and module, and
// ignores what is not one (novox/hq issue 1633, AC2).
// A process connected pinned to a test bus follows the membership the grants part writes for its node and module in
// its state memberships, and reports what is not one (novox/hq issue 1633, AC2); it no longer hears mesh.assignment,
// where the grants part stops publishing at the lease write (ADR 0571 clause 10; issue 774, 774/S5/AC1). Without the
// state it refuses to follow, naming it.
func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) {
url, fingerprint := selfSignedServer(t)
account := mailSenderAccount(t, url, fingerprint)
@@ -20,26 +26,52 @@ func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) {
}
defer conn.Close()
got := make(chan membership.Membership, 1)
got := make(chan membership.Membership, 4)
bad := make(chan error, 4)
if err := conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e }); err != nil {
follow := func() error {
return conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e })
}
if err := follow(); err == nil || !strings.Contains(err.Error(), membership.GrantsBucket) {
t.Fatalf("followed with no state memberships: %v", err)
}
js, err := jetstream.New(conn.nc)
if err != nil {
t.Fatal(err)
}
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte("not json")); err != nil {
ctx := context.Background()
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: membership.GrantsBucket})
if err != nil {
t.Fatal(err)
}
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.1"]}`)); err != nil {
key := membership.GrantsKey("novox", "mesh-mail")
if _, err := kv.Put(ctx, key, []byte("not json")); err != nil {
t.Fatal(err)
}
if err := follow(); err != nil {
t.Fatal(err)
}
// Published on the subject, as the grants part did until the write: not heard.
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.9"]}`)); err != nil {
t.Fatal(err)
}
if _, err := kv.Put(ctx, key, membership.EncodeGrantsMembership(membership.Membership{Node: "novox", Module: "mesh-mail",
Mesh: []string{"10.0.0.1"}})); err != nil {
t.Fatal(err)
}
select {
case m := <-got:
if m.Module != "mesh-mail" || len(m.Mesh) != 1 {
if m.Module != "mesh-mail" || len(m.Mesh) != 1 || m.Mesh[0] != "10.0.0.1" {
t.Fatalf("membership: %+v", m)
}
case <-time.After(5 * time.Second):
t.Fatal("no membership heard")
t.Fatal("no membership followed")
}
select {
case m := <-got:
t.Errorf("a second membership was applied, from the subject: %+v", m)
case <-time.After(300 * time.Millisecond):
}
if len(bad) == 0 {
t.Error("the body that is not a membership was not reported")
t.Error("the value that is not a membership was not reported")
}
}
+73
View File
@@ -230,4 +230,77 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
t.Fatalf("the get answered %v", r)
}
_ = inW.Close()
// ADR 0580 clause 1: the watched copy answers keys and values after the watch's current values, a later put or delete
// changes its answer, a follower's then sees the copy that took its change, each state is watched once, and a
// value kept once is read by key once.
handles, watches, gets := map[string]func(StateChange) error{}, map[string]int{}, 0
w := WatchedStatesOver(func(state string, handle func(StateChange) error) error {
watches[state]++
handles[state] = handle
return handle(StateChange{State: state, Key: "a", Op: OpPut, Value: []byte("1"), Current: true})
}, func(state, key string) ([]byte, bool, error) { gets++; return []byte(key), key != "missing", nil })
var thenSaw []string
w.Follow("placement", func(c StateChange) error {
v, found, _ := w.Get("placement", c.Key)
thenSaw = append(thenSaw, fmt.Sprintf("%s %s %s %v", c.Op, c.Key, v, found))
return nil
})
if keys, err := w.Keys("placement"); err != nil || fmt.Sprint(keys) != "[a]" {
t.Fatalf("the copy's keys are %v (%v)", keys, err)
}
_ = handles["placement"](StateChange{Key: "b", Op: OpPut, Value: []byte("2")})
_ = handles["placement"](StateChange{Key: "a", Op: OpDelete})
vals, _ := w.Values("placement")
if v, found, _ := w.Get("placement", "a"); found || string(vals["b"]) != "2" || len(vals) != 1 || v != nil {
t.Fatalf("after a put and a delete the copy is %v", vals)
}
if fmt.Sprint(thenSaw) != "[put a 1 true put b 2 true delete a false]" {
t.Fatalf("then saw %v", thenSaw)
}
_, _ = w.Keys("placement")
if watches["placement"] != 1 {
t.Fatalf("the state was watched %d times", watches["placement"])
}
// A read that starts a state's watch before its follower joins loses that follower no change: the follower is run over
// the copy's current values when it joins, then hears every later change (review of ADR 0580 §2).
if _, _, err := w.Get("haves", "n1.a"); err != nil {
t.Fatal(err)
}
_ = handles["haves"](StateChange{Key: "n1.b", Op: OpPut, Value: []byte("2")}) // in the gap: no follower yet
var joined []string
w.Follow("haves", func(c StateChange) error { joined = append(joined, c.Key); return nil })
_ = handles["haves"](StateChange{Key: "n2.c", Op: OpPut, Value: []byte("3")})
if fmt.Sprint(joined) != "[a n1.b n2.c]" || watches["haves"] != 1 {
t.Fatalf("a follower joining a watched state heard %v (watched %d times)", joined, watches["haves"])
}
for range 2 {
if v, found, _ := w.Once("manifests", "d1"); !found || string(v) != "d1" {
t.Fatalf("once read %q %v", v, found)
}
_, _, _ = w.Once("manifests", "missing")
}
if gets != 3 {
t.Fatalf("a kept value was read by key %d times, want once, and the missing one twice", gets)
}
// Issue 1954: a state the process alone writes reads its own put and delete back from the copy at once, and the
// watch's changes of that key up to the write are not taken over it; a later change is.
_ = handles["placement"](StateChange{Key: "own", Op: OpPut, Value: []byte("old"), Revision: 5})
if err := w.Wrote("placement", "own", []byte("mine"), 7); err != nil {
t.Fatal(err)
}
_ = w.Wrote("placement", "gone", []byte("x"), 8)
_ = w.Removed("placement", "gone")
for _, c := range []StateChange{{Key: "own", Op: OpPut, Value: []byte("old"), Revision: 6},
{Key: "own", Op: OpPut, Value: []byte("mine"), Revision: 7}, {Key: "gone", Op: OpPut, Value: []byte("x"), Revision: 8}} {
_ = handles["placement"](c)
}
own, _, _ := w.Get("placement", "own")
if _, found, _ := w.Get("placement", "gone"); found || string(own) != "mine" {
t.Fatalf("after its own writes and the watch's older changes the copy holds own=%s, gone found %v", own, found)
}
_ = handles["placement"](StateChange{Key: "gone", Op: OpPut, Value: []byte("again"), Revision: 9})
if v, _, _ := w.Get("placement", "gone"); string(v) != "again" {
t.Fatalf("a change after its own delete is not taken: %s", v)
}
}
+293
View File
@@ -0,0 +1,293 @@
package stdio
import (
"fmt"
"sort"
"sync"
"time"
)
// WatchedStates is a copy of the states a module reads, each filled by its own watch and kept by its changes (novox/hq
// ADR 0580 clause 1): a read of a whole state is one watch's current values, never a get per key, which took a
// composition past 30 seconds and a compare past its call bound. A state is watched on its first read, or on Follow,
// and the watch hands every current value over before the read is answered, so the copy is complete from the first
// answer. A key whose value never changes once written (a manifest, by its digest) is read once with Once and kept.
// A state helper, no rule: what a module reads is its own.
type WatchedStates struct {
watch func(state string, handle func(StateChange) error) error
get func(state, key string) ([]byte, bool, error)
mu sync.Mutex
states map[string]*watchedState
// once holds each value read with Once, by state and key, for the process's life: a few kilobytes per manifest
// digest. Past onceAtMost keys it is emptied, which costs only a read again.
once map[string][]byte
}
// onceAtMost bounds the values Once keeps.
const onceAtMost = 4096
type watchedState struct {
ready chan struct{} // closed once the watch's current values are all in the copy, or it was refused
err error
mu sync.Mutex
values map[string][]byte
// revs is, by key, the revision its value in the copy is at, and latest the highest revision the copy has seen: a
// change the watch hands at a revision not newer than its key's is one the copy already took (Wrote, Removed).
revs map[string]uint64
latest uint64
then []func(StateChange) error
started bool // the current values are in, so a change runs then at once
pending []StateChange // the current values, run through then once all are in
thenMu sync.Mutex // then runs one change at a time
}
// NewWatchedStates is the copy over this bundle's runtime: each state by KeptState.Watch, Once by KeptState.Get.
func NewWatchedStates() *WatchedStates {
return WatchedStatesOver(
func(state string, handle func(StateChange) error) error { return State(state).Watch("", handle) },
func(state, key string) ([]byte, bool, error) {
e, err := State(state).Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
})
}
// WatchedStatesOver is the copy over a watch and a get of one's own (a process's bus, a test's): watch hands handle
// every current value and returns once they are handled, then hands every change; get reads one key.
func WatchedStatesOver(watch func(state string, handle func(StateChange) error) error,
get func(state, key string) ([]byte, bool, error)) *WatchedStates {
return &WatchedStates{watch: watch, get: get, states: map[string]*watchedState{}, once: map[string][]byte{}}
}
// watched is the state's copy, its watch started now when it has none, the caller waiting until its current values
// are in. then, when given, runs after the copy took each change: the current values once they are all in (a then
// that reads the copy reads it whole), each later change as it comes. No lock is held while the watch starts, so a
// then may read any state of the copy.
func (w *WatchedStates) watched(state string, then func(StateChange) error) (*watchedState, error) {
w.mu.Lock()
s, ok := w.states[state]
if ok {
w.mu.Unlock()
<-s.ready
if s.err != nil {
return nil, s.err
}
if then != nil {
s.join(state, then)
}
return s, nil
}
s = &watchedState{ready: make(chan struct{}), values: map[string][]byte{}, revs: map[string]uint64{}}
if then != nil {
s.then = []func(StateChange) error{then}
}
w.states[state] = s
w.mu.Unlock()
err := w.watch(state, func(c StateChange) error {
s.mu.Lock()
switch {
case c.Revision != 0 && c.Revision <= s.revs[c.Key]:
// Already in the copy, or older than what is: this process wrote the key since (Wrote, Removed).
case c.Op == OpPut:
s.values[c.Key] = append([]byte(nil), c.Value...)
s.revs[c.Key] = c.Revision
default:
delete(s.values, c.Key)
s.revs[c.Key] = c.Revision
}
s.latest = max(s.latest, c.Revision)
if !s.started {
s.pending = append(s.pending, c)
s.mu.Unlock()
return nil
}
s.mu.Unlock()
return s.run(state, c)
})
if err != nil {
s.err = fmt.Errorf("the state %s is not watched: %w", state, err)
w.mu.Lock()
delete(w.states, state) // asked again on the next read
w.mu.Unlock()
close(s.ready)
return nil, s.err
}
s.mu.Lock()
s.started = true
pending := s.pending
s.pending = nil
s.mu.Unlock()
close(s.ready)
for _, c := range pending {
if err := s.run(state, c); err != nil {
sayf("%v", err)
}
}
return s, nil
}
// join adds then to a state already watched (a read started its watch first) and runs it over the copy's current values
// before any later change, so no change that landed before it joined is lost to it (review of ADR 0580 §2: a read
// beat the follower to the watch at start-up). Under thenMu, so a later change waits for these; a change the copy took
// just before the snapshot may also be handed again, which a then that wakes work takes as a second wake.
func (s *watchedState) join(state string, then func(StateChange) error) {
s.thenMu.Lock()
defer s.thenMu.Unlock()
s.mu.Lock()
s.then = append(s.then, then)
current := make([]StateChange, 0, len(s.values))
for k, v := range s.values {
current = append(current, StateChange{State: state, Key: k, Op: OpPut, Value: v, Current: true})
}
s.mu.Unlock()
sort.Slice(current, func(i, j int) bool { return current[i].Key < current[j].Key })
for _, c := range current {
if err := then(c); err != nil {
sayf("%s %s: %v", state, c.Key, err)
}
}
}
// run hands one change, already in the copy, to every then.
func (s *watchedState) run(state string, c StateChange) error {
s.thenMu.Lock()
defer s.thenMu.Unlock()
s.mu.Lock()
then := append([]func(StateChange) error(nil), s.then...)
s.mu.Unlock()
var errs []error
for _, f := range then {
if err := f(c); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("%s %s: %v", state, c.Key, errs)
}
return nil
}
// Follow watches the state into the copy and runs then on each change after the copy took it, so a reader then wakes
// never reads a copy older than its change; it asks again until the watch is taken (2 s, doubled to a minute), as
// KeptState.Follow does. On a state already watched then hears the changes from now on.
func (w *WatchedStates) Follow(state string, then func(StateChange) error) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if _, err := w.watched(state, then); err == nil {
return
} else {
sayf("%v; asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
// Get is a key's value in the copy, false when it has none.
func (w *WatchedStates) Get(state, key string) ([]byte, bool, error) {
s, err := w.watched(state, nil)
if err != nil {
return nil, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.values[key]
return v, ok, nil
}
// Keys are the state's keys in the copy, sorted.
func (w *WatchedStates) Keys(state string) ([]string, error) {
s, err := w.watched(state, nil)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
keys := make([]string, 0, len(s.values))
for k := range s.values {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}
// Values is every key's value in the copy.
func (w *WatchedStates) Values(state string) (map[string][]byte, error) {
s, err := w.watched(state, nil)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[string][]byte, len(s.values))
for k, v := range s.values {
out[k] = v
}
return out, nil
}
// Once is a key whose value never changes once written, read by key the first time it is found and kept after: a
// manifest by its digest. A key not found is not kept, so it is read again on the next ask.
func (w *WatchedStates) Once(state, key string) ([]byte, bool, error) {
w.mu.Lock()
v, ok := w.once[state+" "+key]
w.mu.Unlock()
if ok {
return v, true, nil
}
v, found, err := w.get(state, key)
if err != nil || !found {
return nil, false, err
}
w.mu.Lock()
if len(w.once) >= onceAtMost {
clear(w.once)
}
w.once[state+" "+key] = v
w.mu.Unlock()
return v, true, nil
}
// Wrote takes a put this process made into the copy at once, at the revision the bus answered (0 when not known): a
// state the process alone writes (a holder's own) is read back from the copy right after its own write, before the
// watch hands the change back, and the watch's older changes of the key are not taken over it (issue 1954). The state
// is watched first when it is not yet.
func (w *WatchedStates) Wrote(state, key string, value []byte, revision uint64) error {
return w.took(state, key, value, revision, true)
}
// Removed takes a delete this process made into the copy at once, as Wrote takes a put.
func (w *WatchedStates) Removed(state, key string) error { return w.took(state, key, nil, 0, false) }
func (w *WatchedStates) took(state, key string, value []byte, revision uint64, put bool) error {
s, err := w.watched(state, nil)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
switch {
case put && revision != 0:
if revision <= s.revs[key] {
return nil // the watch handed a newer change of the key already
}
s.values[key] = append([]byte(nil), value...)
s.revs[key] = revision
s.latest = max(s.latest, revision)
default:
// A delete's revision is not answered, nor a put's at times: it is newer than every revision the copy has seen,
// so the watch's changes of the key up to that one are not taken over it.
if put {
s.values[key] = append([]byte(nil), value...)
} else {
delete(s.values, key)
}
s.revs[key] = s.latest
}
return nil
}
// Direct is a key read by key now, not from the copy: a value that changes and is read one at a time (a build).
func (w *WatchedStates) Direct(state, key string) ([]byte, bool, error) { return w.get(state, key) }